Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] 2fde123e87 ci: bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-26 19:52:08 +00:00
179 changed files with 592 additions and 2120 deletions
+3 -3
View File
@@ -32,7 +32,7 @@ jobs:
version: ${{ steps.bump.outputs.VERSION }}
sha: ${{ steps.bump.outputs.SHA }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
@@ -107,7 +107,7 @@ jobs:
PLATFORM: ${{ matrix.platform }}
run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV"
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: ${{ needs.version-bump.outputs.sha }}
@@ -157,7 +157,7 @@ jobs:
timeout-minutes: 120
needs: [version-bump, build]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: ${{ needs.version-bump.outputs.sha }}
fetch-depth: 0
+4 -4
View File
@@ -56,7 +56,7 @@ jobs:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
@@ -179,7 +179,7 @@ jobs:
PLATFORM: ${{ matrix.platform }}
run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV"
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: ${{ needs.version-bump.outputs.sha }}
@@ -229,7 +229,7 @@ jobs:
timeout-minutes: 120
needs: [version-bump, build]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: ${{ needs.version-bump.outputs.sha }}
@@ -294,7 +294,7 @@ jobs:
needs: version-bump
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: ${{ needs.version-bump.outputs.sha }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
run:
working-directory: plugin-sdk
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
# The Node the TREK image runs the plugins on. It has the node:sqlite
+2 -2
View File
@@ -34,7 +34,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
persist-credentials: false
@@ -61,7 +61,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
persist-credentials: false
+19 -19
View File
@@ -127,7 +127,7 @@ jobs:
image: ${{ steps.areas.outputs.image }}
deploy: ${{ steps.areas.outputs.deploy }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
# On a pull request HEAD is the merge commit GitHub builds of the PR
# into its base, and its first parent is the base, so two commits
@@ -202,7 +202,7 @@ jobs:
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
@@ -218,7 +218,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
@@ -289,7 +289,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
@@ -437,7 +437,7 @@ jobs:
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
@@ -532,7 +532,7 @@ jobs:
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
@@ -572,7 +572,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
@@ -603,7 +603,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
@@ -670,7 +670,7 @@ jobs:
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
@@ -765,7 +765,7 @@ jobs:
--health-timeout 5s
--health-retries 20
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
persist-credentials: false
@@ -817,7 +817,7 @@ jobs:
matrix:
shard: [1, 2, 3, 4, 5, 6, 7]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
@@ -861,7 +861,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
@@ -906,7 +906,7 @@ jobs:
matrix:
shard: [1, 2, 3, 4, 5, 6, 7]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
@@ -945,7 +945,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
@@ -984,7 +984,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
@@ -1018,7 +1018,7 @@ jobs:
working-directory: plugin-sdk
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
@@ -1069,7 +1069,7 @@ jobs:
- plugin-sdk
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
# Sonar reads git blame to date each issue and to decide what counts
# as new code. A shallow clone leaves it blind to both.
@@ -1133,7 +1133,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
persist-credentials: false
@@ -1226,7 +1226,7 @@ jobs:
KUBECONFORM_VERSION: v0.8.0
KUBECONFORM_SHA256: 9bc2bffbf71f261128533edaf912153948b7ff238f9a531ae6d34466ec287883
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
persist-credentials: false
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Publish to GitHub wiki
uses: Andrew-Chen-Wang/github-wiki-action@1bbb4280446f9630e8e21a18012cbacf3b0f992e # v5.0.6
with:
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 5.0.1
version: 5.0.0
description: Minimal Helm chart for TREK app
appVersion: "5.0.1"
appVersion: "5.0.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "5.0.1",
"version": "5.0.0",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
+2 -2
View File
@@ -1,7 +1,7 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 382,
"@typescript-eslint/no-misused-promises": 848,
"@typescript-eslint/no-misused-promises": 849,
"no-console": 88,
"complexity": 220,
"@typescript-eslint/no-explicit-any": 146,
@@ -30,7 +30,7 @@
"no-useless-escape": 1
},
"suppressed": {
"@typescript-eslint/no-explicit-any": 38,
"@typescript-eslint/no-explicit-any": 40,
"react-hooks/exhaustive-deps": 46
},
"tests": {
+1 -1
View File
@@ -7,7 +7,7 @@
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1385,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2218,
"src/components/Map/MapViewGL.tsx": 2290,
"src/components/Map/RouteCalculator.ts": 1063,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
@@ -440,8 +440,6 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
currency: { labelKey: 'assistant.change.label.currency' },
stopType: { labelKey: 'assistant.change.label.stopType' },
text: { labelKey: 'assistant.change.label.text' },
title: { labelKey: 'assistant.change.label.title' },
content: { labelKey: 'assistant.change.label.content' },
icon: { labelKey: 'assistant.change.label.icon' },
color: { labelKey: 'assistant.change.label.color' },
position: { labelKey: 'assistant.change.label.position' },
@@ -1,4 +1,4 @@
// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-032
// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-030
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { render, screen, fireEvent, waitFor, within } from '../../../../tests/helpers/render'
@@ -66,7 +66,6 @@ function renderCard(
canManage?: boolean
providerName?: string
onReconnect?: () => void
onEditConnection?: () => void
} = {},
) {
const utils = render(
@@ -76,7 +75,6 @@ function renderCard(
sync={makeSync(opts.sync)}
canManage={opts.canManage ?? true}
onReconnect={opts.onReconnect}
onEditConnection={opts.onEditConnection}
/>,
)
const header = utils.container.querySelector('header') as HTMLElement
@@ -423,31 +421,3 @@ describe('DocSyncBinding: the direction lanes', () => {
expect(updateLink).not.toHaveBeenCalled()
})
})
describe('DocSyncBinding: the connection behind it', () => {
const S = en as unknown as Record<string, string>
const editButton = () => screen.queryByRole('button', { name: S['docsync.connection.edit'] })
it('FE-DOCSYNC-BIND-031: the settings open the stored connection at any time, not only after a refusal', () => {
const onEditConnection = vi.fn()
renderCard({ link: { lastSyncState: 'ok' }, onEditConnection })
expect(editButton()).not.toBeInTheDocument()
openSettings()
expect(screen.getByText(S['docsync.connection.title'])).toBeInTheDocument()
fireEvent.click(editButton() as HTMLElement)
expect(onEditConnection).toHaveBeenCalledTimes(1)
})
it('FE-DOCSYNC-BIND-032: a member, or a panel without the form, gets no way to the connection', () => {
const member = renderCard({ canManage: false, onEditConnection: vi.fn() })
expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument()
expect(editButton()).not.toBeInTheDocument()
member.unmount()
renderCard()
openSettings()
expect(editButton()).not.toBeInTheDocument()
})
})
@@ -1,6 +1,6 @@
import { useState } from 'react'
import {
AlertTriangle, Check, ChevronDown, Clock, Copy, FolderOpen, KeyRound, Link2Off, RefreshCw,
AlertTriangle, Check, ChevronDown, Clock, Copy, FolderOpen, Link2Off, RefreshCw,
} from 'lucide-react'
import ConfirmDialog from '../../shared/ConfirmDialog'
import CustomSelect from '../../shared/CustomSelect'
@@ -27,7 +27,6 @@ export default function DocSyncBinding({
sync,
canManage,
onReconnect,
onEditConnection,
}: {
link: DocSyncLink
providerName: string
@@ -39,8 +38,6 @@ export default function DocSyncBinding({
* not carry; left out, a refused credential is reported but not curable.
*/
onReconnect?: () => void
/** The same form at any time, to change the address or the login, or to remove the connection. */
onEditConnection?: () => void
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
@@ -240,21 +237,6 @@ export default function DocSyncBinding({
</div>
</div>
)}
{onEditConnection && (
<Row label={t('docsync.connection.title')} hint={t('docsync.connection.hint')}>
<span className="flex justify-end">
<button
type="button"
onClick={onEditConnection}
className="flex items-center gap-1.5 rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
>
<KeyRound size={13} />
{t('docsync.connection.edit')}
</button>
</span>
</Row>
)}
</div>
)}
</>
@@ -1,4 +1,4 @@
// FE-DOCSYNC-CONNECT-001 to FE-DOCSYNC-CONNECT-030
// FE-DOCSYNC-CONNECT-001 to FE-DOCSYNC-CONNECT-027
import { describe, it, expect, vi, beforeEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { render, screen, fireEvent, waitFor, act, within } from '../../../../tests/helpers/render'
@@ -13,7 +13,6 @@ const listLinks = vi.fn(async (_tripId: number | string): Promise<unknown> => []
const status = vi.fn(async (_tripId: number | string): Promise<unknown> => ({ items: {}, links: [] }))
const saveConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ id: 1 }))
const testConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ connected: true }))
const deleteConnection = vi.fn(async (_tripId: number | string, _id: number): Promise<unknown> => ({ success: true }))
vi.mock('../../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api/client')>()
@@ -27,7 +26,6 @@ vi.mock('../../../api/client', async (importOriginal) => {
listLinks: (tripId: number | string) => listLinks(tripId),
saveConnection: (tripId: number | string, data: unknown) => saveConnection(tripId, data),
testConnection: (tripId: number | string, data: unknown) => testConnection(tripId, data),
deleteConnection: (tripId: number | string, id: number) => deleteConnection(tripId, id),
},
}
})
@@ -83,13 +81,11 @@ function Harness({
provider,
onClose,
onConnected,
onRemoved,
waitForConnection,
}: {
provider: DocSyncProvider
onClose: () => void
onConnected: (providerId: string) => void
onRemoved?: () => void
/**
* Hold the dialog back until the stored connection is in hand, the way the
* panel does: it only offers "connect" once it knows what is already there.
@@ -98,9 +94,7 @@ function Harness({
}) {
const sync = useDocSync(TRIP_ID, true)
if (waitForConnection && !sync.connectionFor(provider.id)) return null
return (
<DocSyncConnectModal provider={provider} sync={sync} onClose={onClose} onConnected={onConnected} onRemoved={onRemoved} />
)
return <DocSyncConnectModal provider={provider} sync={sync} onClose={onClose} onConnected={onConnected} />
}
/** Let the hook's initial load land before anything is asserted. */
@@ -498,52 +492,3 @@ describe('DocSyncConnectModal with a connection already stored', () => {
expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({ baseUrl: 'https://cloud.example.com' }))
})
})
describe('DocSyncConnectModal removal', () => {
const removeButton = () => screen.queryByRole('button', { name: tx('docsync.connection.remove') })
// The confirmation is portalled after the dialog, so its button is the last one of that name.
const confirmRemoval = () => {
const named = screen.getAllByRole('button', { name: tx('docsync.connection.remove') })
return named[named.length - 1]
}
async function openWithRemoval(stored: boolean) {
if (stored) listConnections.mockResolvedValue([connection()])
const onRemoved = vi.fn()
render(<Harness provider={nextcloud} onClose={vi.fn()} onConnected={vi.fn()} onRemoved={onRemoved} waitForConnection={stored} />)
await flush()
return onRemoved
}
it('FE-DOCSYNC-CONNECT-028: a stored connection can be removed after a confirmation, and the dialog is told', async () => {
const onRemoved = await openWithRemoval(true)
await waitFor(() => expect(removeButton()).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: tx('docsync.connection.remove') }))
expect(deleteConnection).not.toHaveBeenCalled()
await screen.findByText(tx('docsync.connection.confirmRemove'))
fireEvent.click(confirmRemoval())
await waitFor(() => expect(deleteConnection).toHaveBeenCalledWith(TRIP_ID, 9))
await waitFor(() => expect(onRemoved).toHaveBeenCalled())
})
it('FE-DOCSYNC-CONNECT-029: a refused removal keeps the dialog and says why', async () => {
deleteConnection.mockRejectedValueOnce({ response: { status: 403, data: { error: 'forbidden' } } })
const onRemoved = await openWithRemoval(true)
await waitFor(() => expect(removeButton()).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: tx('docsync.connection.remove') }))
await screen.findByText(tx('docsync.connection.confirmRemove'))
fireEvent.click(confirmRemoval())
await waitFor(() => expect(deleteConnection).toHaveBeenCalled())
expect(onRemoved).not.toHaveBeenCalled()
expect(await screen.findByText(tx('docsync.error.forbidden'))).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-030: a first connection has nothing to remove', async () => {
await openWithRemoval(false)
expect(removeButton()).not.toBeInTheDocument()
})
})
@@ -1,7 +1,6 @@
import { useId, useState } from 'react'
import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react'
import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
import ConfirmDialog from '../../shared/ConfirmDialog'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
import { Tooltip } from '../../shared/Tooltip'
import ToggleSwitch from '../../Settings/ToggleSwitch'
import { useTranslation } from '../../../i18n/TranslationContext'
@@ -23,14 +22,11 @@ export default function DocSyncConnectModal({
sync,
onClose,
onConnected,
onRemoved,
}: {
provider: DocSyncProvider
sync: ReturnType<typeof useDocSync>
onClose: () => void
onConnected: (providerId: string) => void
/** After the stored connection was removed from here; left out, the form offers no removal. */
onRemoved?: () => void
}) {
const { t } = useTranslation()
const existing = sync.connectionFor(provider.id)
@@ -38,11 +34,6 @@ export default function DocSyncConnectModal({
const form = useConnectForm(provider, existing, sync)
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
const [confirmRemove, setConfirmRemove] = useState(false)
const remove = async () => {
if (existing && (await sync.removeConnection(existing.id))) onRemoved?.()
}
const test = async () => setVerdict(await form.probe())
const labelId = useId()
@@ -55,7 +46,6 @@ export default function DocSyncConnectModal({
<DialogShell
onClose={onClose}
labelledBy={labelId}
blocked={confirmRemove}
header={(
<DialogHeader
tile={<DialogTile>{Icon ? <Icon className="h-5 w-5 text-content" /> : <Plug size={20} strokeWidth={1.9} className="text-content-muted" />}</DialogTile>}
@@ -67,13 +57,6 @@ export default function DocSyncConnectModal({
)}
footer={(
<DialogFooter>
{existing && onRemoved && (
<DeleteButton
label={t('docsync.connection.remove')}
onClick={() => setConfirmRemove(true)}
disabled={sync.busy === 'connection'}
/>
)}
<Verdict verdict={verdict} busy={sync.busy === 'test'} failure={sync.error} />
<FooterSpacer />
<DialogButton onClick={() => void test()} disabled={!form.complete || sync.busy === 'test'}>
@@ -118,15 +101,6 @@ export default function DocSyncConnectModal({
</span>
</label>
</div>
<ConfirmDialog
isOpen={confirmRemove}
onClose={() => setConfirmRemove(false)}
onConfirm={() => void remove()}
title={t('docsync.connection.remove')}
message={t('docsync.connection.confirmRemove')}
confirmLabel={t('docsync.connection.remove')}
/>
</DialogShell>
)
}
@@ -62,9 +62,6 @@ export default function DocSyncPanel({
setSelected(prev => (sync.links.some(l => l.id === prev) ? prev : (sync.links[0]?.id ?? null)))
}, [sync.links])
// The connection behind the folder picker, which the picker can send back to.
const scopeProvider = scopeFor ? sync.providers.find(p => p.id === scopeFor) : undefined
const attention = ATTENTION_STATES.reduce((n, k) => n + (sync.itemCounts[k] ?? 0), 0)
const labelId = useId()
@@ -116,7 +113,6 @@ export default function DocSyncPanel({
sync={sync}
canManage={canManage}
onReconnect={activeProvider ? () => setReconnecting(activeProvider) : undefined}
onEditConnection={canManage && activeProvider ? () => setReconnecting(activeProvider) : undefined}
/>
{/* Every action in this column reports here: a refused run, a
switch that flipped back, a conflict the server would not
@@ -145,7 +141,6 @@ export default function DocSyncPanel({
sync={sync}
onClose={() => setConnecting(null)}
onConnected={id => { setConnecting(null); setScopeFor(id) }}
onRemoved={() => setConnecting(null)}
/>
)}
@@ -154,7 +149,6 @@ export default function DocSyncPanel({
provider={reconnecting}
sync={sync}
onClose={() => setReconnecting(null)}
onRemoved={() => setReconnecting(null)}
onConnected={id => {
setReconnecting(null)
// Run straight away, as binding does: the card still reports the
@@ -174,7 +168,6 @@ export default function DocSyncPanel({
sync={sync}
onClose={() => setScopeFor(null)}
onBound={() => setScopeFor(null)}
onEditConnection={scopeProvider ? () => { setScopeFor(null); setConnecting(scopeProvider) } : undefined}
/>
)}
</>
@@ -1,4 +1,4 @@
// FE-DOCSYNC-SCOPE-001 to FE-DOCSYNC-SCOPE-024
// FE-DOCSYNC-SCOPE-001 to FE-DOCSYNC-SCOPE-023
/**
* The folder picker of the document-sync dialog.
@@ -84,10 +84,9 @@ function deferred<T>() {
return { promise, resolve, reject }
}
function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless', onEditConnection }: {
function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless' }: {
suggestedName?: string
providerName?: string
onEditConnection?: () => void
}) {
const sync = useDocSync(TRIP_ID, false)
return (
@@ -98,7 +97,6 @@ function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless',
sync={sync}
onClose={onClose}
onBound={onBound}
onEditConnection={onEditConnection}
/>
)
}
@@ -383,20 +381,3 @@ describe('DocSyncScopeModal errors', () => {
expect(createButton()).toBeEnabled()
})
})
describe('DocSyncScopeModal connection', () => {
it('FE-DOCSYNC-SCOPE-024: leads back to the stored connection before a folder is chosen', async () => {
const editButton = () => screen.queryByRole('button', { name: S['docsync.connection.edit'] })
const onEditConnection = vi.fn()
const first = render(<Harness onEditConnection={onEditConnection} />)
await waitFor(() => expect(listScopes).toHaveBeenCalled())
fireEvent.click(editButton() as HTMLElement)
expect(onEditConnection).toHaveBeenCalledTimes(1)
expect(createLink).not.toHaveBeenCalled()
first.unmount()
render(<Harness />)
expect(editButton()).not.toBeInTheDocument()
})
})
@@ -1,5 +1,5 @@
import { useId, useMemo, useState } from 'react'
import { FolderPlus, KeyRound, Loader2, Search } from 'lucide-react'
import { FolderPlus, Loader2, Search } from 'lucide-react'
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../../shared/DialogShell'
import { useTranslation } from '../../../i18n/TranslationContext'
import type { DocSyncConnection, useDocSync } from './useDocSync'
@@ -22,7 +22,6 @@ export default function DocSyncScopeModal({
sync,
onClose,
onBound,
onEditConnection,
}: {
connection: DocSyncConnection
providerName: string
@@ -31,8 +30,6 @@ export default function DocSyncScopeModal({
sync: ReturnType<typeof useDocSync>
onClose: () => void
onBound: () => void
/** Back to the stored connection, to change the login or remove it before choosing a folder. */
onEditConnection?: () => void
}) {
const { t } = useTranslation()
const labelId = useId()
@@ -66,19 +63,7 @@ export default function DocSyncScopeModal({
)}
>
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<p className="text-caption text-content-muted">{t('docsync.scope.intro')}</p>
{onEditConnection && (
<button
type="button"
onClick={onEditConnection}
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-edge px-2.5 py-1 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
>
<KeyRound size={13} />
{t('docsync.connection.edit')}
</button>
)}
</div>
<p className="text-caption text-content-muted">{t('docsync.scope.intro')}</p>
<div className="rounded-xl border border-edge bg-surface-secondary p-3">
<span className="mb-2 block text-body font-medium text-content">{t('docsync.scope.createTitle')}</span>
@@ -1,4 +1,4 @@
// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-032
// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-030
/**
* The document-sync hook.
@@ -22,7 +22,6 @@ const testConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown)
const updateLinkApi = vi.fn(async (_tripId: number | string, _linkId: number, _patch: unknown): Promise<unknown> => ({}))
const syncNowApi = vi.fn(async (_tripId: number | string, _linkId: number, _full: boolean): Promise<unknown> => ({}))
const listScopesApi = vi.fn(async (_tripId: number | string, _connectionId: number, _q?: string): Promise<unknown> => ({ scopes: [] }))
const deleteConnectionApi = vi.fn(async (_tripId: number | string, _id: number): Promise<unknown> => ({ success: true }))
const resolveApi = vi.fn(async (_tripId: number | string, _itemId: number, _keep: string): Promise<unknown> => ({ success: true }))
vi.mock('../../../api/client', async (importOriginal) => {
@@ -41,7 +40,6 @@ vi.mock('../../../api/client', async (importOriginal) => {
syncNow: (tripId: number | string, linkId: number, full: boolean) => syncNowApi(tripId, linkId, full),
listScopes: (tripId: number | string, connectionId: number, q?: string) => listScopesApi(tripId, connectionId, q),
resolve: (tripId: number | string, itemId: number, keep: string) => resolveApi(tripId, itemId, keep),
deleteConnection: (tripId: number | string, id: number) => deleteConnectionApi(tripId, id),
},
}
})
@@ -128,7 +126,6 @@ beforeEach(() => {
syncNowApi.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })
listScopesApi.mockResolvedValue({ scopes: [] })
resolveApi.mockResolvedValue({ success: true })
deleteConnectionApi.mockResolvedValue({ success: true })
})
describe('useDocSync initial load', () => {
@@ -474,38 +471,6 @@ describe('useDocSync resolveConflict', () => {
})
})
describe('useDocSync removeConnection', () => {
it('FE-DOCSYNC-HOOK-031: forgets the connection and reloads, so its bindings leave the screen too', async () => {
const { result } = await mountLoaded()
expect(result.current.links).toHaveLength(1)
// The server drops the bindings with the connection (ON DELETE CASCADE).
listConnectionsApi.mockResolvedValue([])
listLinksApi.mockResolvedValue([])
let ok: boolean | undefined
await act(async () => { ok = await result.current.removeConnection(9) })
expect(ok).toBe(true)
expect(deleteConnectionApi).toHaveBeenCalledWith(TRIP, 9)
expect(result.current.connectionFor('nextcloud')).toBeNull()
expect(result.current.links).toEqual([])
expect(result.current.busy).toBeNull()
})
it('FE-DOCSYNC-HOOK-032: a refused removal keeps everything and says why', async () => {
deleteConnectionApi.mockRejectedValueOnce({ response: { status: 403, data: { error: 'forbidden' } } })
const { result } = await mountLoaded()
let ok: boolean | undefined
await act(async () => { ok = await result.current.removeConnection(9) })
expect(ok).toBe(false)
expect(result.current.error).toBe('forbidden')
expect(result.current.connectionFor('nextcloud')).not.toBeNull()
expect(result.current.busy).toBeNull()
})
})
describe('needsReauth', () => {
it('FE-DOCSYNC-HOOK-030: reads a refused credential off either the state or the error code', () => {
expect(needsReauth({ lastSyncState: 'needs_reauth', lastSyncError: null })).toBe(true)
@@ -264,27 +264,6 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
}
}, [tripId, load])
/**
* Forget a stored connection, and with it every binding of this trip that
* runs through it. The documents stay on both sides; what goes is the
* address and the login TREK kept for the store, which is the one thing a
* person changing accounts or folders had no way to take back.
*/
const removeConnection = useCallback(async (connectionId: number) => {
setBusy('connection')
setError(null)
try {
await docsyncApi.deleteConnection(tripId, connectionId)
await load()
return true
} catch (e: unknown) {
setError(readError(e))
return false
} finally {
setBusy(null)
}
}, [tripId, load])
/**
* Probe without saving. Always resolves to a verdict object: the route
* answers 200 even for an unreachable instance, because a form showing a typo
@@ -448,7 +427,7 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
connections, links, itemCounts,
loading, busy, error,
connectionFor, load,
saveConnection, testConnection, removeConnection,
saveConnection, testConnection,
loadScopes, createScope,
createLink, updateLink, removeLink, syncNow, resolveConflict,
}
+9 -13
View File
@@ -102,20 +102,16 @@ export default function HelpPanel(): React.ReactElement | null {
<HelpNav context={context} currentId={contextId} activeGuideId={!searching && guide ? guide.id : null} />
<div className="relative flex flex-col min-h-0 bg-surface" role="main">
{/* The close button sits in a row of its own above the scrolling content rather
than floating over it, so no card ever runs up against it. */}
<div className="flex shrink-0 justify-end px-4 pt-4">
<button
type="button"
onClick={closeHelp}
aria-label={t('help.center.close')}
className="w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<button
type="button"
onClick={closeHelp}
aria-label={t('help.center.close')}
className="absolute top-4 end-4 z-10 w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
>
<X className="w-4 h-4" />
</button>
<div ref={scrollRef} className="trek-help-scroll flex-1 min-h-0 overflow-y-auto overscroll-contain">
<div className="max-w-[1080px] mx-auto px-6 sm:px-10 pt-4 pb-8">
<div className="max-w-[1080px] mx-auto px-6 sm:px-10 py-8">
{searching ? (
<HelpSearchResults key="search" />
) : guide ? (
@@ -1,4 +1,4 @@
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-008
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-007
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
@@ -119,28 +119,3 @@ describe('DatePicker week start (#2029)', () => {
}
})
})
describe('DatePicker inside a clipping dialog (#2858)', () => {
it('FE-COMP-JOURNEYDATEPICKER-008: the calendar floats over the page, stays inside the viewport and closes on a click away', async () => {
const user = userEvent.setup()
const { container } = render(
<div style={{ overflow: 'hidden' }}>
<DatePicker value="2026-03-15" onChange={vi.fn()} />
</div>,
)
const trigger = screen.getByText(/Mar 15, 2026/).closest('button') as HTMLButtonElement
// A trigger at the right edge, where the end date of a range sits.
trigger.getBoundingClientRect = () => ({ top: 100, bottom: 136, left: window.innerWidth - 120, right: window.innerWidth - 8, width: 112, height: 36, x: 0, y: 0, toJSON: () => ({}) })
await user.click(trigger)
const day31 = screen.getByRole('button', { name: '31' })
// Rendered outside the dialog that would clip it.
expect(container.contains(day31)).toBe(false)
const panel = day31.closest('.fixed') as HTMLElement
expect(panel.style.top).toBe('140px')
expect(parseFloat(panel.style.left)).toBe(window.innerWidth - 280 - 8)
await user.click(document.querySelector('[role="presentation"]') as HTMLElement)
expect(screen.queryByRole('button', { name: '31' })).toBeNull()
})
})
@@ -1,11 +1,9 @@
import { useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useState } from 'react'
import { localIsoDate } from '../../utils/localDate'
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useWeekStartDay } from '../../hooks/useWeekStartDay'
import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek'
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
/**
* The header steps up a level on each click, days → months → years, and a
@@ -16,9 +14,6 @@ import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
*/
type CalendarView = 'days' | 'months' | 'years'
const YEAR_PAGE_SIZE = 12
/** The calendar's width; `w-[280px]` below. */
const PANEL_WIDTH = 280
const VIEWPORT_PAD = 8
export function DatePicker({ value, onChange, tripDates }: {
value: string
@@ -28,13 +23,6 @@ export function DatePicker({ value, onChange, tripDates }: {
const { t, locale } = useTranslation()
const weekStart = useWeekStartDay()
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
// The calendar used to hang absolutely under its trigger, inside dialogs that clip
// their content: the photo picker cut off the last week and, for the end date in
// the right-hand column, the last weekday (#2858). It now floats over the page,
// pinned to the trigger, flipped above it when below is too short and kept inside
// the viewport sideways.
const anchored = useAnchoredPosition(triggerRef, open, { estimatedHeight: 340, matchWidth: false })
const [view, setView] = useState<CalendarView>('days')
const [yearPageStart, setYearPageStart] = useState(0)
const [viewMonth, setViewMonth] = useState(() => {
@@ -119,7 +107,6 @@ export function DatePicker({ value, onChange, tripDates }: {
return (
<div className="relative">
<button
ref={triggerRef}
type="button"
onClick={toggleOpen}
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-start flex items-center justify-between"
@@ -135,20 +122,12 @@ export function DatePicker({ value, onChange, tripDates }: {
<Calendar size={13} className="text-zinc-400" />
</button>
{open && anchored && createPortal(
{open && (
<>
{/* Click-away catcher — no semantics of its own; the trigger button
above closes the popover again from the keyboard. */}
<div role="presentation" className="fixed inset-0 z-[var(--z-toast)]" onClick={() => setOpen(false)} />
<div
className="fixed z-[var(--z-toast)] overflow-y-auto bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]"
style={{
top: anchored.top,
bottom: anchored.bottom,
left: Math.max(VIEWPORT_PAD, Math.min(anchored.left, window.innerWidth - PANEL_WIDTH - VIEWPORT_PAD)),
maxHeight: anchored.maxHeight,
}}
>
<div role="presentation" className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
<div className="absolute top-full start-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
{/* Header: arrows step the current view, the label climbs to the next one up */}
<div className="flex items-center justify-between mb-2">
<button type="button" onClick={handlePrev} aria-label={prevLabel} className={navButton}>
@@ -221,7 +200,7 @@ export function DatePicker({ value, onChange, tripDates }: {
>
{day}
{isTrip && !isSelected && (
<span className="absolute bottom-1 inset-x-0 mx-auto w-1 h-1 rounded-full bg-indigo-500" />
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
)}
</button>
)
@@ -230,8 +209,7 @@ export function DatePicker({ value, onChange, tripDates }: {
</>
)}
</div>
</>,
document.body,
</>
)}
</div>
)
@@ -375,12 +375,11 @@ describe('ProviderPicker', () => {
await user.click(screen.getByRole('button', { name: 'Search' }))
expect(ranges).toHaveLength(0)
// The calendar opens in a portal, so its days are looked up on the page, not under the trigger.
const [fromPicker, toPicker] = Array.from(baseElement.querySelectorAll('.flex-1 > .relative')) as HTMLElement[]
await user.click(within(fromPicker).getByRole('button'))
await user.click(screen.getByRole('button', { name: '3' }))
await user.click(within(fromPicker).getByRole('button', { name: '3' }))
await user.click(within(toPicker).getByRole('button'))
await user.click(screen.getByRole('button', { name: '9' }))
await user.click(within(toPicker).getByRole('button', { name: '9' }))
await user.click(screen.getByRole('button', { name: 'Search' }))
await waitFor(() => expect(ranges).toHaveLength(1))
+91 -18
View File
@@ -50,10 +50,16 @@ import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import {
PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID, PLACE_CLUSTER_SOURCE_ID,
addPlaceClusterLayers, drawClusterBubbles, expandCluster,
} from './placeClusterLayers'
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers'
// Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich
// HTML photo markers *and* cluster them natively, so we feed the place points
// into a clustered GeoJSON source. The cluster bubbles render as GL circles +
// a count label; the individual rich HTML markers are then only drawn for the
// points the source reports as currently unclustered. Grouping is always on,
// matching the Leaflet map's MarkerClusterGroup.
const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters'
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
type PlaceWithCoords = Place & { lat: number; lng: number }
@@ -106,6 +112,72 @@ interface Props extends MapViewProps {
gl: any
}
/**
* How eagerly place markers merge into a cluster.
*
* The overview band the Leaflet map clusters in as well (`CLUSTER_UNTIL_ZOOM` there),
* kept as plain numbers rather than shared constants so the GL bundle does not have to
* carry Leaflet for them. Above the cutoff supercluster hands every point back on its
* own, and the stops that share a coordinate are folded together at the pin level
* instead — see the reconcile below.
*/
const CLUSTER_RADIUS = 20
const CLUSTER_MAX_ZOOM = 8
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
* change and the initial build cannot drift apart. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function addPlaceClusterLayers(map: any): void {
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
clusterRadius: CLUSTER_RADIUS,
clusterMaxZoom: CLUSTER_MAX_ZOOM,
})
map.addLayer({
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
paint: {
'circle-color': '#111827',
'circle-opacity': 0.97,
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
'circle-stroke-width': 2.5,
'circle-stroke-color': 'rgba(255,255,255,0.9)',
},
})
map.addLayer({
id: PLACE_CLUSTER_COUNT_LAYER_ID,
type: 'symbol',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 12,
'text-allow-overlap': true,
},
paint: {
'text-color': '#ffffff',
'text-halo-color': 'rgba(17,24,39,0.35)',
'text-halo-width': 1,
},
})
map.addLayer({
id: PLACE_UNCLUSTERED_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': 24,
'circle-opacity': 0,
'circle-stroke-opacity': 0,
},
})
}
function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
// A stop that interrupts the drive gets its own small disc, decided before the photo
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
@@ -540,8 +612,6 @@ export function MapViewGL({
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<Map<number, PlacePin>>(new Map())
/** The count bubbles, one per cluster the source currently reports. */
const clusterPinsRef = useRef<PlacePin[]>([])
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
const pinLayerRef = useRef<HTMLDivElement | null>(null)
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
@@ -572,7 +642,6 @@ export function MapViewGL({
// in the same frame the canvas draws, or the ones left out swim against the ones in.
const repositionPins = useCallback(() => {
markersRef.current.forEach(pin => pin.reposition())
clusterPinsRef.current.forEach(pin => pin.reposition())
poiMarkersRef.current.forEach(pin => pin.reposition())
pluginMarkersRef.current.forEach(pin => pin.reposition())
routeViaMarkersRef.current.forEach(pin => pin.reposition())
@@ -1235,7 +1304,21 @@ export function MapViewGL({
? map.queryRenderedFeatures(e.point, { layers: [PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID] })
: []
const feature = features?.[0]
expandCluster(map, feature?.properties?.cluster_id, feature?.geometry?.coordinates)
const clusterId = feature?.properties?.cluster_id
const coordinates = feature?.geometry?.coordinates
if (clusterId == null || !Array.isArray(coordinates)) return
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const source = map.getSource(PLACE_CLUSTER_SOURCE_ID) as any
const easeToZoom = (nextZoom: number) => {
try { map.easeTo({ center: coordinates, zoom: nextZoom, duration: 350 }) } catch { /* noop */ }
}
try {
const maybeZoom = source?.getClusterExpansionZoom?.(clusterId, (err: Error | null, nextZoom: number) => {
if (!err && typeof nextZoom === 'number') easeToZoom(nextZoom)
})
if (typeof maybeZoom === 'number') easeToZoom(maybeZoom)
else if (maybeZoom && typeof maybeZoom.then === 'function') maybeZoom.then(easeToZoom).catch(() => {})
} catch { /* noop */ }
}
const setClusterCursor = () => {
const canvas = typeof map.getCanvas === 'function' ? map.getCanvas() : null
@@ -1621,17 +1704,8 @@ export function MapViewGL({
source.setData(buildPlaceClusterData(places) as any)
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
let raf: number | null = null
const dropClusterPins = () => {
clusterPinsRef.current.forEach(pin => pin.remove())
clusterPinsRef.current = []
}
const reconcileClusterPins = () => {
dropClusterPins()
clusterPinsRef.current = drawClusterBubbles(map, (el, lng, lat) => attachPin(map, gl, pinLayerRef.current, el, lng, lat))
}
const runReconcile = () => {
raf = null
reconcileClusterPins()
const features = map.querySourceFeatures(PLACE_CLUSTER_SOURCE_ID, { filter: ['!', ['has', 'point_count']] }) || []
const seen = new Set<number>()
const visiblePlaces: PlaceWithCoords[] = []
@@ -1678,7 +1752,6 @@ export function MapViewGL({
return () => {
if (raf !== null) cancelAnimationFrame(raf)
dropClusterPins()
map.off('moveend', scheduleReconcile)
map.off('zoomend', scheduleReconcile)
map.off('idle', scheduleReconcile)
+1 -2
View File
@@ -1,6 +1,5 @@
import L from 'leaflet'
import { STACK_RADIUS_PX } from './coincidentPlaces'
import { clusterBubbleSize } from './placeClusterLayers'
/** Up to here the map is an overview, where stops merge to keep the world readable. */
export const CLUSTER_UNTIL_ZOOM = 9
@@ -67,7 +66,7 @@ export interface ClusterLike {
/** A count bubble sized by its count, styled by `.marker-cluster-custom` in index.css. */
export function createClusterIcon(cluster: ClusterLike) {
const count = cluster.getChildCount()
const size = clusterBubbleSize(count)
const size = count < 10 ? 36 : count < 50 ? 42 : 48
return L.divIcon({
html: `<div class="marker-cluster-custom" style="width:${size}px;height:${size}px;"><span>${count}</span></div>`,
className: 'marker-cluster-wrapper',
@@ -1,86 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import {
type ClusterMap,
PLACE_CLUSTER_SOURCE_ID,
addPlaceClusterLayers,
clusterBubbleSize,
createClusterBubble,
drawClusterBubbles,
} from './placeClusterLayers';
function fakeMap(features: unknown[], expansionZoom: unknown = 11) {
const source = { getClusterExpansionZoom: vi.fn(() => expansionZoom) };
const map = {
addSource: vi.fn(),
addLayer: vi.fn(),
getSource: vi.fn((id: string) => (id === PLACE_CLUSTER_SOURCE_ID ? source : undefined)),
easeTo: vi.fn(),
querySourceFeatures: vi.fn((_id: string, query: { filter: unknown[] }) =>
query.filter[0] === 'has' ? features : []
),
};
return { map: map as unknown as ClusterMap & typeof map, source };
}
describe('cluster bubbles', () => {
it('FE-COMP-PLACECLUSTER-001: a bubble grows in three steps with its count', () => {
expect([1, 9, 10, 49, 50, 500].map(clusterBubbleSize)).toEqual([36, 36, 42, 42, 48, 48]);
});
it('FE-COMP-PLACECLUSTER-002: the GL bubble is the Leaflet markup inside a bare wrapper the pin can move', () => {
const wrapper = createClusterBubble(38);
expect(wrapper.className).toBe('');
const bubble = wrapper.firstElementChild as HTMLElement;
expect(bubble.className).toBe('marker-cluster-custom');
expect(bubble.style.width).toBe('42px');
expect(bubble.style.height).toBe('42px');
expect(bubble.textContent).toBe('38');
});
it('FE-COMP-PLACECLUSTER-003: the canvas circle and count stay as an invisible hit area', () => {
const { map } = fakeMap([]);
addPlaceClusterLayers(map);
expect(map.addSource).toHaveBeenCalledWith(PLACE_CLUSTER_SOURCE_ID, expect.objectContaining({ cluster: true }));
const layers = map.addLayer.mock.calls.map(([layer]) => layer as { id: string; paint: Record<string, unknown> });
expect(layers.find((l) => l.id === 'trip-place-clusters-circle')?.paint['circle-opacity']).toBe(0);
expect(layers.find((l) => l.id === 'trip-place-clusters-count')?.paint['text-opacity']).toBe(0);
});
it('FE-COMP-PLACECLUSTER-004: one bubble per cluster, placed at its centre, that zooms in on a click', () => {
const cluster = { properties: { cluster_id: 5, point_count: 12 }, geometry: { coordinates: [139.7, 35.6] } };
const { map, source } = fakeMap([
cluster,
cluster, // the same cluster crossing a tile edge
{ properties: { cluster_id: 6 }, geometry: { coordinates: [1, 1] } }, // no count
{ properties: { cluster_id: 7, point_count: 3 } }, // no geometry
]);
const placed: { el: HTMLElement; lng: number; lat: number }[] = [];
const pins = drawClusterBubbles(map, (el, lng, lat) => {
placed.push({ el, lng, lat });
return lng;
});
expect(pins).toEqual([139.7]);
expect(placed[0].el.textContent).toBe('12');
expect(placed[0].lat).toBe(35.6);
const onMapClick = vi.fn();
document.body.appendChild(placed[0].el);
document.body.addEventListener('click', onMapClick);
placed[0].el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
document.body.removeEventListener('click', onMapClick);
placed[0].el.remove();
// The click is the bubble's: the map underneath never sees it.
expect(onMapClick).not.toHaveBeenCalled();
expect(source.getClusterExpansionZoom).toHaveBeenCalledWith(5, expect.any(Function));
expect(map.easeTo).toHaveBeenCalledWith({ center: [139.7, 35.6], zoom: 11, duration: 350 });
});
it('FE-COMP-PLACECLUSTER-005: a source that reports nothing draws nothing', () => {
const { map } = fakeMap([]);
map.querySourceFeatures.mockReturnValue(null as never);
expect(drawClusterBubbles(map, () => 1)).toEqual([]);
});
});
@@ -5,169 +5,3 @@
*/
export const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle';
export const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count';
// Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich
// HTML photo markers *and* cluster them natively, so we feed the place points
// into a clustered GeoJSON source. The source decides what is clustered; the
// bubbles are drawn as DOM (`drawClusterBubbles`) and the individual rich HTML
// markers only for the points the source reports as currently unclustered.
// Grouping is always on, matching the Leaflet map's MarkerClusterGroup.
export const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters';
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit';
/**
* How eagerly place markers merge into a cluster.
*
* The overview band the Leaflet map clusters in as well (`CLUSTER_UNTIL_ZOOM` there),
* kept as plain numbers rather than shared constants so the GL bundle does not have to
* carry Leaflet for them. Above the cutoff supercluster hands every point back on its
* own, and the stops that share a coordinate are folded together at the pin level
* instead — see the reconcile in MapViewGL.
*/
const CLUSTER_RADIUS = 20;
const CLUSTER_MAX_ZOOM = 8;
/** The part of either SDK's map the cluster code calls. */
export interface ClusterMap {
addSource: (id: string, source: object) => void;
addLayer: (layer: object) => void;
getSource: (id: string) => unknown;
easeTo: (options: { center: unknown[]; zoom: number; duration: number }) => void;
querySourceFeatures: (id: string, options: { filter: unknown[] }) => ClusterFeature[] | null | undefined;
}
interface ClusterFeature {
properties?: { cluster_id?: unknown; point_count?: unknown } | null;
geometry?: { coordinates?: unknown };
}
interface ClusterSource {
getClusterExpansionZoom?: (id: unknown, done: (err: Error | null, zoom: number) => void) => unknown;
}
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
* change and the initial build cannot drift apart. */
export function addPlaceClusterLayers(map: Pick<ClusterMap, 'addSource' | 'addLayer'>): void {
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
clusterRadius: CLUSTER_RADIUS,
clusterMaxZoom: CLUSTER_MAX_ZOOM,
});
// Invisible: the bubble the user sees is DOM (createClusterBubble), these two
// layers stay as the hit area the track and route layers check a click against.
map.addLayer({
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
paint: {
'circle-opacity': 0,
'circle-stroke-opacity': 0,
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
},
});
map.addLayer({
id: PLACE_CLUSTER_COUNT_LAYER_ID,
type: 'symbol',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 12,
'text-allow-overlap': true,
},
paint: { 'text-opacity': 0 },
});
map.addLayer({
id: PLACE_UNCLUSTERED_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': 24,
'circle-opacity': 0,
'circle-stroke-opacity': 0,
},
});
}
/** Eases the map in to the zoom a cluster falls apart at. Both SDKs answer that
* question, one through a callback and one through a promise. */
export function expandCluster(
map: Pick<ClusterMap, 'getSource' | 'easeTo'>,
clusterId: unknown,
coordinates: unknown
): void {
if (clusterId == null || !Array.isArray(coordinates)) return;
const source = map.getSource(PLACE_CLUSTER_SOURCE_ID) as ClusterSource | undefined;
const easeToZoom = (nextZoom: number) => {
try {
map.easeTo({ center: coordinates, zoom: nextZoom, duration: 350 });
} catch {
/* noop */
}
};
try {
const maybeZoom = source?.getClusterExpansionZoom?.(clusterId, (err, nextZoom) => {
if (!err && typeof nextZoom === 'number') easeToZoom(nextZoom);
});
if (typeof maybeZoom === 'number') easeToZoom(maybeZoom);
else if (maybeZoom instanceof Promise) maybeZoom.then(easeToZoom).catch(() => {});
} catch {
/* noop */
}
}
/**
* Puts one count bubble on the map per cluster the source currently reports, through
* `place` (the caller's pin factory), and returns what `place` handed back. A cluster
* crossing a tile edge comes back once per tile, so bubbles are keyed by cluster id.
*/
export function drawClusterBubbles<T>(map: ClusterMap, place: (el: HTMLElement, lng: number, lat: number) => T): T[] {
const pins: T[] = [];
const seen = new Set<unknown>();
for (const feature of map.querySourceFeatures(PLACE_CLUSTER_SOURCE_ID, { filter: ['has', 'point_count'] }) ?? []) {
const clusterId = feature?.properties?.cluster_id;
const count = feature?.properties?.point_count;
const coordinates = feature?.geometry?.coordinates;
if (typeof count !== 'number' || !Array.isArray(coordinates) || seen.has(clusterId)) continue;
seen.add(clusterId);
const el = createClusterBubble(count);
el.addEventListener('click', (ev) => {
ev.stopPropagation();
expandCluster(map, clusterId, coordinates);
});
pins.push(place(el, coordinates[0], coordinates[1]));
}
return pins;
}
/** A count bubble's diameter in pixels: both renderers size their bubbles by it. */
export function clusterBubbleSize(count: number): number {
return count < 10 ? 36 : count < 50 ? 42 : 48;
}
/**
* A count bubble as DOM, styled by `.marker-cluster-custom` in index.css.
*
* The GL map draws its bubbles with this element rather than as canvas text, so they
* read in the app's own font and get the same hover the Leaflet map has. A canvas
* symbol layer can only use the glyphs the map style ships and knows no `:hover`.
* The bubble sits in a bare wrapper because the pin positions its element through
* `transform`, which would otherwise override the hover's scale.
*/
export function createClusterBubble(count: number): HTMLDivElement {
const size = clusterBubbleSize(count);
const bubble = document.createElement('div');
bubble.className = 'marker-cluster-custom';
bubble.style.width = `${size}px`;
bubble.style.height = `${size}px`;
const label = document.createElement('span');
label.textContent = String(count);
bubble.appendChild(label);
const wrapper = document.createElement('div');
wrapper.appendChild(bubble);
return wrapper;
}
@@ -485,17 +485,6 @@ describe('KategorieGruppe — inline add item', () => {
expect(onAddItem).not.toHaveBeenCalled()
})
it('FE-W5CAT-028a: Enter on the first item typed into the placeholder row opens the add field (#2825)', async () => {
server.use(http.put('/api/trips/1/packing/1', () => HttpResponse.json({ item: buildPackingItem({ id: 1, name: 'Tent', category: 'Gear' }) })))
setup({ items: [buildPackingItem({ id: 1, name: '...', category: 'Gear' })] })
fireEvent.click(screen.getByText('...'))
fireEvent.change(screen.getByPlaceholderText('...'), { target: { value: 'Tent' } })
fireEvent.keyDown(screen.getByPlaceholderText('...'), { key: 'Enter' })
await waitFor(() => expect(screen.getByPlaceholderText('Item name...')).toHaveFocus())
})
it('FE-W5CAT-028: the confirm button adds the item, and stays inert while empty', () => {
const onAddItem = vi.fn(async () => {})
const { container } = setup({ onAddItem })
@@ -86,7 +86,6 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
const [showAddItem, setShowAddItem] = useState(false)
const [newItemName, setNewItemName] = useState('')
const addItemRef = useRef<HTMLInputElement>(null)
const openAddItem = () => { setShowAddItem(true); setTimeout(() => addItemRef.current?.focus(), 30) }
const menuBtnRef = useRef<HTMLButtonElement>(null)
const assigneeDropdownRef = useRef<HTMLDivElement>(null)
const { togglePackingItem } = useTripStore()
@@ -293,7 +292,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
const contributions = contribFor(item.id)
return (
<React.Fragment key={item.id}>
<ArtikelZeile item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} onPlaceholderFilled={openAddItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} divider={index > 0}
<ArtikelZeile item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} divider={index > 0}
tripMembers={tripMembers} currentUserId={currentUserId} onSetSharing={onSetSharing} onClone={onClone} onJoin={onJoin} onLeave={onLeave}
drag={canEdit && reorderable ? {
isDragging: dragId === item.id,
@@ -344,7 +343,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
// A row of its own on a faint tint, so it closes the card instead
// of trailing off: a dashed box in the checkbox column (past the drag
// grip's width) and the label where an item's name sits.
<button type="button" onClick={openAddItem}
<button type="button" onClick={() => { setShowAddItem(true); setTimeout(() => addItemRef.current?.focus(), 30) }}
style={{ display: 'flex', alignItems: 'center', gap: 9, width: 'calc(100% - 8px)', margin: '6px 4px 0', paddingBlock: 8, paddingInlineEnd: 10, paddingInlineStart: 28, borderRadius: 10, border: 'none', background: 'var(--bg-secondary)', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, color: 'var(--text-faint)', fontFamily: 'inherit', textAlign: 'start' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
@@ -257,23 +257,6 @@ describe('ArtikelZeile — renaming', () => {
await waitFor(() => expect(screen.getByText('...')).toBeInTheDocument())
})
it('FE-W5ROW-008b: Enter on a filled placeholder row hands the typing on, a blur does not (#2825)', async () => {
server.use(http.put('/api/trips/1/packing/1', () => HttpResponse.json({ item: buildPackingItem({ id: 1, name: 'Tent' }) })))
const onPlaceholderFilled = vi.fn()
setup({ item: buildPackingItem({ id: 1, name: '...', category: 'Gear' }), onPlaceholderFilled })
fireEvent.click(screen.getByText('...'))
fireEvent.change(screen.getByPlaceholderText('...'), { target: { value: 'Tent' } })
fireEvent.blur(screen.getByPlaceholderText('...'))
await waitFor(() => expect(screen.queryByPlaceholderText('...')).toBeNull())
expect(onPlaceholderFilled).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('...'))
fireEvent.change(screen.getByPlaceholderText('...'), { target: { value: 'Tent' } })
fireEvent.keyDown(screen.getByPlaceholderText('...'), { key: 'Enter' })
await waitFor(() => expect(onPlaceholderFilled).toHaveBeenCalledTimes(1))
})
it('FE-W5ROW-009: a failing rename surfaces a save error', async () => {
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
setup()
@@ -25,8 +25,6 @@ interface ArtikelZeileProps {
categories: string[]
onCategoryChange: () => void
onDelete?: (item: PackingItem) => Promise<void>
/** Enter on the filled '...' row, so the category's add field can take over the typing (#2825). */
onPlaceholderFilled?: () => void
bagTrackingEnabled?: boolean
bags?: PackingBag[]
onCreateBag: (name: string) => Promise<PackingBag | undefined>
@@ -51,7 +49,7 @@ interface ArtikelZeileProps {
}
}
export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCategoryChange, onDelete, onPlaceholderFilled, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, tripMembers = [], currentUserId, onSetSharing, onClone, onJoin, onLeave, divider = false, drag }: ArtikelZeileProps) {
export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, tripMembers = [], currentUserId, onSetSharing, onClone, onJoin, onLeave, divider = false, drag }: ArtikelZeileProps) {
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
@@ -82,12 +80,9 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
const handleSaveName = async (viaEnter = false) => {
const handleSaveName = async () => {
if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return }
try {
await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false)
if (viaEnter && isPlaceholder) onPlaceholderFilled?.()
}
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
catch { toast.error(t('packing.toast.saveError')) }
}
@@ -193,8 +188,8 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
type="text" value={editName} autoFocus
placeholder={isPlaceholder ? '...' : undefined}
onChange={e => setEditName(e.target.value)}
onBlur={() => void handleSaveName()}
onKeyDown={e => { if (e.key === 'Enter') void handleSaveName(true); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }}
onBlur={handleSaveName}
onKeyDown={e => { if (e.key === 'Enter') void handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }}
style={{ flex: 1, minWidth: 0, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
/>
) : canEdit ? (
@@ -138,34 +138,6 @@ describe('TransitSearchPanel', () => {
expect(payload.metadata.transit.legs[1]).toMatchObject({ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', headsign: 'Ruhleben' })
})
it('FE-PLANNER-TRANSIT-003b: routes Google answered name Google Maps and are saved as Google’s (#2850)', async () => {
const user = userEvent.setup()
const onAdd = vi.fn().mockResolvedValue({})
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY], provider: 'google' })
render(<TransitSearchPanel {...makeProps({ onAdd })} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await user.click(await screen.findByText(/08:30 – 09:00/))
// Google's terms want the words as written, so the browser must not translate them.
expect(screen.getByText('Google Maps')).toHaveAttribute('translate', 'no')
expect(screen.queryByRole('link', { name: 'Transitous' })).not.toBeInTheDocument()
await user.click(await screen.findByRole('button', { name: 'Add to day' }))
await waitFor(() => expect(onAdd).toHaveBeenCalled())
expect(onAdd.mock.calls[0][0].metadata.transit.provider).toBe('google')
})
it('FE-PLANNER-TRANSIT-003c: routes Transitous answered credit Transitous with its sources link', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY], provider: 'transitous' })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
expect(await screen.findByRole('link', { name: 'Transitous' })).toHaveAttribute('href', 'https://transitous.org/sources/')
expect(screen.queryByText('Google Maps')).not.toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-004: search failure shows the empty state, not a crash', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockRejectedValueOnce(new Error('boom'))
@@ -604,7 +604,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
notes: null,
metadata: {
transit: {
provider: provider ?? 'transitous',
provider: 'transitous',
duration: it.duration,
transfers: it.transfers,
walk_seconds: it.walkSeconds,
@@ -760,10 +760,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
))}
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', textAlign: 'center', marginTop: 2 }}>
{t('transit.attribution')}{' '}
{/* Named after the backend that answered; Google's terms want "Google Maps" as written, never translated. */}
{provider === 'google'
? <span translate="no">Google Maps</span>
: <a href="https://transitous.org/sources/" target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline' }}>Transitous</a>}
<a href="https://transitous.org/sources/" target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline' }}>Transitous</a>
</div>
</div>
)}
@@ -803,34 +803,6 @@ describe('useRoadtripRoutes', () => {
}
})
it('FE-ROADTRIP-ROUTES-052: a join this device routed before is not paced like a request (#2794)', async () => {
// A join kept its request slot whatever answered it, so a month of connected days
// waited half a minute on every open with every road already on the device. The
// clock is held still, so every answer reads as one from the cache; a join that
// still waited its turn would wait 1.1 s of real time, past what waitFor gives it.
const clock = vi.spyOn(performance, 'now').mockImplementation(() => 10_000)
try {
const MUNICH: [number, number] = [48.1374, 11.5755]
const INNSBRUCK: [number, number] = [47.2692, 11.4041]
const BOZEN: [number, number] = [46.4983, 11.3548]
setting({ roadtrip_connect_days: true })
const daysList = [day(1, 1), day(2, 2), day(3, 3)]
const assignments = {
...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]),
...map(2, [{ id: 3, at: BERLIN, time: '09:00' }, { id: 4, at: MUNICH }]),
...map(3, [{ id: 5, at: INNSBRUCK, time: '09:00' }, { id: 6, at: BOZEN }]),
} as AssignmentsMap
renderHook(() => useRoadtripRoutes(7, daysList, assignments))
await waitFor(() => {
expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1)
expect(askedFor(MUNICH, INNSBRUCK)).toHaveLength(1)
})
} finally {
clock.mockRestore()
}
})
it('FE-ROADTRIP-ROUTES-051: a join OSRM answered in the engine’s place is drawn, flagged, and asked for again on request', async () => {
// Filed like any answer, it stood in for the weighed road until the page was loaded
// again, and a picker on it read the stand-in line as the road already taken.
@@ -692,19 +692,11 @@ export function useRoadtripRoutes(
* for both, so a choice being checked cannot fire into the gap a seam is keeping.
*/
const lastSpacedRequestAt = useRef(0)
/**
* Waits until the gap since the last such request is kept, then claims the next slot.
* Hands back what gives the slot up again, for a request the cache answered.
*/
const spacedRequest = useCallback(async (signal: AbortSignal): Promise<() => void> => {
/** Waits until the gap since the last such request is kept, then claims the next slot. */
const spacedRequest = useCallback(async (signal: AbortSignal): Promise<void> => {
const since = performance.now() - lastSpacedRequestAt.current
if (since < REQUEST_SPACING_MS) await sleep(REQUEST_SPACING_MS - since, signal)
const before = lastSpacedRequestAt.current
const claimed = performance.now()
if (!signal.aborted) lastSpacedRequestAt.current = claimed
return () => {
if (lastSpacedRequestAt.current === claimed) lastSpacedRequestAt.current = before
}
if (!signal.aborted) lastSpacedRequestAt.current = performance.now()
}, [])
useEffect(() => {
@@ -735,7 +727,7 @@ export function useRoadtripRoutes(
// shared routing hosts answered the tail with 429, and a refused seam
// writes no state, so nothing changed to make the effect try again: the
// map drew the trip in pieces and the totals came back short.
const release = await spacedRequest(controller.signal)
await spacedRequest(controller.signal)
if (controller.signal.aborted) return
try {
// The via points on this seam, threaded in the same way the day runs thread
@@ -743,7 +735,6 @@ export function useRoadtripRoutes(
// shape: it is asked for on its own, so the points the traveller dropped on it
// never reached the router and dragging one did visibly nothing.
const shaping = viasOnLeg(seam.from, seam.to, viasByDay[seam.from.ownerDayId] ?? [])
const startedAt = performance.now()
const r = await calculateRouteWithLegs(
[
{ lat: seam.from.lat, lng: seam.from.lng },
@@ -752,10 +743,6 @@ export function useRoadtripRoutes(
],
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: seam.dayId, avoid },
)
// A seam this device routed before came back from its cache and went nowhere, so
// its slot goes back before the store below re-runs this effect. Holding it made a
// trip of connected days wait a second per join on every open (#2794).
if (performance.now() - startedAt <= CACHE_HIT_MS) release()
if (controller.signal.aborted) return
// One leg per waypoint PAIR, so a shaped seam comes back in pieces and the rail
// wants the whole drive: merged here, exactly as a day run folds its via legs
@@ -212,7 +212,7 @@ function TourTimeFields({ planner, locked }: TourFieldProps) {
id="tour-planner-planned-duration"
label={t('tours.planner.plannedTotalDuration')}
value={planner.plannedDurationMinutes ?? planner.plannedTotalMinutes}
invalid={planner.plannedDurationInvalid || planner.plannedTotalInvalid}
invalid={planner.plannedDurationInvalid}
errorId="tour-planner-planned-duration-error"
locked={locked}
onCommit={planner.setPlannedDurationMinutes}
@@ -220,10 +220,6 @@ function TourTimeFields({ planner, locked }: TourFieldProps) {
{planner.plannedDurationInvalid && (
<FieldError id="tour-planner-planned-duration-error">{t('tours.planner.timeValueInvalid')}</FieldError>
)}
{/* #2869: over a day a stage Save waits, and says why rather than only greying out. */}
{!planner.plannedDurationInvalid && planner.plannedTotalInvalid && (
<FieldError id="tour-planner-planned-duration-error">{t('tours.planner.plannedTotalOverDay')}</FieldError>
)}
</div>
</div>
);
@@ -341,11 +337,7 @@ export function TourDetailsSection({
}: TourFieldProps & { onDifficultyChange: (value: Difficulty) => void }) {
const { t } = useTranslation();
const [open, setOpen] = useState(readDetailsOpen);
const forcedOpen =
planner.websiteInvalid ||
planner.plannedDurationInvalid ||
planner.breakAdditionalInvalid ||
planner.plannedTotalInvalid;
const forcedOpen = planner.websiteInvalid || planner.plannedDurationInvalid || planner.breakAdditionalInvalid;
const expanded = open || forcedOpen;
const toggle = () => {
const next = !expanded;
@@ -526,18 +526,6 @@ describe('TourPlannerRail', () => {
expect(screen.getByRole('alert')).toHaveTextContent('Enter a whole value from 0 to 1,440 minutes.')
})
it('says why Save waits on a tour over a day a stage, and opens the details to say it (#2869)', () => {
window.localStorage.clear()
render(<TourPlannerRail planner={planner({
mode: { type: 'new-draft' }, hasUnsavedChanges: true, canSave: false,
walkingDurationMinutes: 1500, plannedTotalMinutes: 1500, plannedTotalInvalid: true,
})} />)
expect(screen.getByLabelText(/Planned total duration/)).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByRole('alert')).toHaveTextContent(
'Walking time and breaks add up to more than 24 hours a day. Make it a multi-day tour with “End stage here” at a stop, or enter a shorter planned total.',
)
})
it('RS-02: Info controls are keyboard focusable and explain themselves only through the shared tooltip', async () => {
render(<TourPlannerRail planner={planner({
mode: { type: 'new-draft' }, hasUnsavedChanges: true, walkingDurationMinutes: 80,
@@ -1,4 +1,4 @@
// FE-DOCSYNC-MOBILE-001 to FE-DOCSYNC-MOBILE-027
// FE-DOCSYNC-MOBILE-001 to FE-DOCSYNC-MOBILE-024
import type { ComponentProps } from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor, within } from '../../../../../tests/helpers/render'
@@ -137,12 +137,6 @@ beforeEach(() => {
vi.spyOn(docsyncApi, 'createLink').mockResolvedValue({})
vi.spyOn(docsyncApi, 'items').mockResolvedValue([])
vi.spyOn(docsyncApi, 'resolve').mockResolvedValue({ success: true })
vi.spyOn(docsyncApi, 'deleteConnection').mockImplementation(async (_tripId, id) => {
// The bindings go with their connection, as ON DELETE CASCADE has it.
connections = connections.filter(c => c.id !== id)
links = links.filter(l => l.connectionId !== id)
return { success: true }
})
})
/** The status route with a number of conflicts on it, holdings as usual. */
@@ -534,45 +528,3 @@ describe('MDocSyncSheet: what a member may do', () => {
expect(screen.queryByRole('button', { name: /Papra/ })).not.toBeInTheDocument()
})
})
describe('MDocSyncSheet: the stored connection', () => {
const removeButtons = () => screen.getAllByRole('button', { name: 'Remove connection' })
it('FE-DOCSYNC-MOBILE-025: a binding opens its connection at any time, and removing it asks first', async () => {
renderSheet()
await openDetail()
fireEvent.click(screen.getByRole('button', { name: 'Edit connection' }))
expect(await screen.findByPlaceholderText('https://paperless.example')).toHaveValue('https://paperless.example')
fireEvent.click(removeButtons()[0])
expect(docsyncApi.deleteConnection).not.toHaveBeenCalled()
expect(await screen.findByText(/TREK forgets the address and login/)).toBeInTheDocument()
// The confirmation sheet opens over the form, so its button is the last one of that name.
const named = removeButtons()
fireEvent.click(named[named.length - 1])
await waitFor(() => expect(docsyncApi.deleteConnection).toHaveBeenCalledWith(TRIP_ID, 5))
// Back at the list, with Paperless offered again from scratch.
expect(await screen.findByText('Connect a provider')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Sync now' })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-MOBILE-026: a stored connection without a binding can be edited from the folder picker', async () => {
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: /Nextcloud/ }))
await screen.findByText('Photos')
fireEvent.click(screen.getByRole('button', { name: 'Edit connection' }))
expect(await screen.findByRole('button', { name: 'Remove connection' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
})
it('FE-DOCSYNC-MOBILE-027: a member sees no way to the connection', async () => {
renderSheet({ canManage: false })
await openDetail()
expect(screen.queryByRole('button', { name: 'Edit connection' })).not.toBeInTheDocument()
})
})
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import {
ArrowLeft, ArrowRight, Check, ChevronLeft, ChevronRight, FolderPlus, FolderSync, KeyRound, Link2Off,
Loader2, Plus, RefreshCw, Trash2,
Loader2, Plus, RefreshCw,
} from 'lucide-react'
import MSheet from '../../../components/MSheet'
import MIconBtn from '../../../components/MIconBtn'
@@ -149,7 +149,6 @@ export default function MDocSyncSheet({
provider={pending}
sync={sync}
onDone={connected}
onRemoved={() => { setReconnectFor(null); setPending(null); setView('list') }}
/>
) : view === 'scope' && pending && sync.connectionFor(pending.id) ? (
<ScopeView
@@ -157,7 +156,6 @@ export default function MDocSyncSheet({
suggestedName={slugFor(tripTitle, tripId)}
sync={sync}
onBound={() => setView('list')}
onEditConnection={() => setView('connect')}
/>
) : view === 'detail' && link ? (
<DetailView
@@ -419,19 +417,6 @@ function DetailView({
</button>
</SettingRow>
{onReconnect && (
<SettingRow label={t('docsync.connection.title')} hint={t('docsync.connection.hint')}>
<button
type="button"
onClick={onReconnect}
className="flex items-center gap-1.5 rounded-full bg-[color:var(--m-ic)] px-3 py-[6px] font-geist text-caption font-bold text-m-ink"
>
<KeyRound size={12} strokeWidth={2.2} />
{t('docsync.connection.edit')}
</button>
</SettingRow>
)}
<button
type="button"
onClick={onUnlink}
@@ -458,18 +443,14 @@ function ConnectView({
provider,
sync,
onDone,
onRemoved,
}: {
provider: DocSyncProvider
sync: ReturnType<typeof useDocSync>
onDone: () => void
onRemoved: () => void
}) {
const { t } = useTranslation()
const existing = sync.connectionFor(provider.id)
const form = useConnectForm(provider, existing, sync)
const form = useConnectForm(provider, sync.connectionFor(provider.id), sync)
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
const [confirmRemove, setConfirmRemove] = useState(false)
return (
<div className="mt-3 flex flex-col gap-3">
@@ -553,32 +534,6 @@ function ConnectView({
{sync.busy === 'save' ? <Loader2 size={15} className="mx-auto animate-spin" /> : t('common.save')}
</button>
</div>
{existing && (
<button
type="button"
onClick={() => setConfirmRemove(true)}
disabled={sync.busy === 'connection'}
className="flex h-11 w-full items-center justify-center gap-2 rounded-2xl border border-[color:var(--m-rowbr)] text-body font-bold text-[color:var(--m-st-danger)] disabled:opacity-50"
>
<Trash2 size={15} strokeWidth={2.2} />
{t('docsync.connection.remove')}
</button>
)}
<MConfirmSheet
open={confirmRemove}
onClose={() => setConfirmRemove(false)}
title={t('docsync.connection.remove')}
message={t('docsync.connection.confirmRemove')}
confirmLabel={t('docsync.connection.remove')}
cancelLabel={t('common.cancel')}
danger
onConfirm={() => {
setConfirmRemove(false)
if (existing) void sync.removeConnection(existing.id).then(ok => { if (ok) onRemoved() })
}}
/>
</div>
)
}
@@ -589,14 +544,11 @@ function ScopeView({
suggestedName,
sync,
onBound,
onEditConnection,
}: {
connectionId: number
suggestedName: string
sync: ReturnType<typeof useDocSync>
onBound: () => void
/** Back to the stored connection, to change the login or remove it before choosing a folder. */
onEditConnection: () => void
}) {
const { t } = useTranslation()
const { scopes, error, name, setName, working, bind, createAndBind } = useScopePicker({
@@ -610,15 +562,6 @@ function ScopeView({
<div className="mt-3 flex flex-col gap-3">
<p className="font-geist text-[0.71875rem] leading-snug text-m-muted">{t('docsync.scope.intro')}</p>
<button
type="button"
onClick={onEditConnection}
className="flex h-9 items-center justify-center gap-1.5 self-start rounded-full bg-[color:var(--m-ic)] px-3 font-geist text-caption font-bold text-m-ink"
>
<KeyRound size={12} strokeWidth={2.2} />
{t('docsync.connection.edit')}
</button>
<div className="flex gap-2">
<input
value={name}
+3 -3
View File
@@ -13,9 +13,9 @@ import { studioLayerActions, type StudioLayerActions } from './studioLayers'
* The book being edited.
*
* History is snapshot-based rather than patch-based, and deliberately so: a
* snapshot shares every page it did not change with the one before it, so even
* a book of hundreds of pages costs one page per step, and a snapshot stack
* that cannot possibly disagree with the document beats a patch stack that can. It is capped so a
* document is a few hundred kilobytes of plain JSON at most (the schema caps it
* at 150 spreads of 60 elements), and a snapshot stack that cannot possibly
* disagree with the document beats a patch stack that can. It is capped so a
* long session does not grow without limit.
*
* Granularity is the part that matters for how it *feels*: a drag is one undo
@@ -268,11 +268,9 @@ describe('the entry spreads', () => {
})
describe('the document as a whole', () => {
it('lays out a long journey whole, its last page and back cover after the entries', () => {
it('stays within the schema cap on spreads', () => {
const many = Array.from({ length: 200 }, (_, i) => entry({ id: i + 1 }))
const doc = build({ entries: many })
expect(doc.spreads.length).toBeGreaterThan(150)
expect(doc.spreads.slice(-2).map(sp => sp.role)).toEqual(['last', 'back'])
expect(build({ entries: many }).spreads.length).toBeLessThanOrEqual(150)
})
it('gives every spread and every element an id of its own', () => {
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "@trek/root",
"version": "5.0.1",
"version": "5.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@trek/root",
"version": "5.0.1",
"version": "5.0.0",
"workspaces": [
"client",
"server",
@@ -29,7 +29,7 @@
},
"client": {
"name": "@trek/client",
"version": "5.0.1",
"version": "5.0.0",
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@fontsource/abril-fatface": "^5.3.0",
@@ -25005,7 +25005,7 @@
},
"server": {
"name": "@trek/server",
"version": "5.0.1",
"version": "5.0.0",
"dependencies": {
"@aws-lite/client": "^0.23.7",
"@aws-lite/s3": "^0.2.7",
@@ -25384,7 +25384,7 @@
},
"shared": {
"name": "@trek/shared",
"version": "5.0.1",
"version": "5.0.0",
"dependencies": {
"isomorphic-dompurify": "^3.23.0",
"zod": "^4.6.7"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@trek/root",
"private": true,
"version": "5.0.1",
"version": "5.0.0",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/server",
"version": "5.0.1",
"version": "5.0.0",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
+1 -1
View File
@@ -7,7 +7,7 @@
"src/nest/budget/budget.mcp.ts": 36,
"src/nest/collab/collab.mcp.ts": 20,
"src/nest/collections/collections.mcp.ts": 14,
"src/nest/day-notes/day-notes.mcp.ts": 6,
"src/nest/day-notes/day-notes.mcp.ts": 10,
"src/nest/days/days.mcp.ts": 2,
"src/nest/doc-sync/doc-sync.mcp.ts": 1,
"src/nest/files/files.mcp.ts": 4,
+2 -4
View File
@@ -177,15 +177,13 @@ export async function buildApp(): Promise<INestApplication> {
* it is the wrong one for a photo book. A Studio document is sent WHOLE on
* every autosave — deliberately, see book-store.schema.ts, because a patch
* protocol would need an ordering guarantee and a merge rule per field — and
* a book has as many pages as its journey needs. A real book of a
* fortnight's journey is well past a hundred kilobytes before anybody asks
* the contract already caps it at 150 spreads of 90 elements. A real book of
* a fortnight's journey is well past a hundred kilobytes before anybody asks
* for road geometry, and what the ceiling produces is not an error message
* but a save that quietly fails and an editor that says "not saved" without
* saying why.
*
* So the book route is measured against the size a book can actually be.
* An auto laid page is two to five kilobytes, so eight megabytes hold a
* book of a few thousand pages: the page count has no limit of its own.
*
* A list file is the same story at a smaller scale. Its contract allows a
* megabyte and the import posts it whole, yet a list of a few hundred places
-6
View File
@@ -375,12 +375,6 @@ const aiProviderStateSchema = z.discriminatedUnion('adapter', [
adapter: z.literal('anthropic-messages'),
blocks: z.array(z.record(z.string(), z.unknown())),
}),
// Gemini's thought signature per tool call id, which its Chat Completions endpoint wants back with the call.
z.object({
adapter: z.literal('openai-chat'),
model: z.string(),
signatures: z.record(z.string(), z.string()),
}),
]);
export type AiProviderState = z.infer<typeof aiProviderStateSchema>;
@@ -1,6 +1,6 @@
import { safeFetch, safeFetchLlm } from '../../utils/ssrfGuard';
import type { AiChatClient } from './ai-chat-client.interface';
import { AI_BASE_URLS, ownServerChatBase } from './ai-connections.constants';
import { AI_BASE_URLS } from './ai-connections.constants';
import type { ResolvedAiConnection } from './ai-connections.types';
import { ChatgptAuthService } from './chatgpt-auth.service';
import { AnthropicMessagesClient } from './providers/anthropic-messages.client';
@@ -67,7 +67,7 @@ export class AiChatClientService {
return new OpenAiChatClient(
kind,
model,
ownServerChatBase(connection.baseUrl),
(connection.baseUrl ?? '').replace(/(?<!\/)\/+$/, ''),
credential.apiKey,
adminServerFetch,
);
@@ -22,26 +22,6 @@ export function aiHostOf(kind: AiConnectionKind, baseUrl: string | null): string
}
}
/**
* Where an own server's Chat Completions live. An address with a path, such as
* `https://api.groq.com/openai/v1` or `http://ollama.lan:11434/v1`, is used as
* entered. A bare server address gets `/v1`: Ollama, LM Studio, vLLM,
* llama.cpp and LiteLLM all answer there, while Ollama answers
* `/chat/completions` at its root with a 404. Connecting already found the
* model list of such an address under the root, so the chat failed where the
* connection had worked.
*/
export function ownServerChatBase(baseUrl: string | null): string {
const entered = (baseUrl ?? '').trim().replace(/(?<!\/)\/+$/, '');
try {
const url = new URL(entered);
if (url.pathname === '/' && !url.search && !url.hash) return `${entered}/v1`;
} catch {
// An address that does not parse fails on the request, as it did before.
}
return entered;
}
/** Whether OpenRouter's OAuth can send the browser back here: it only returns to an https address. */
export function openrouterOAuthAvailable(): boolean {
return getAppUrl().startsWith('https://');
@@ -26,29 +26,16 @@ import type { AiConnectionKind } from '@trek/shared';
/** The end of an OpenAI-compatible stream. */
const DONE = '[DONE]';
/** The host of Gemini's OpenAI-compatible endpoint, which an admin may also enter as their own server. */
const GEMINI_HOST = 'generativelanguage.googleapis.com';
function isGeminiHost(baseUrl: string): boolean {
try {
return new URL(baseUrl).hostname === GEMINI_HOST;
} catch {
return false;
}
}
/**
* The provider-neutral history as Chat Completions messages, the instructions
* and the trip card first. A tool message takes no image, so the images of a
* step's tool results follow its last result as one user message. `model` is
* the one the request goes to: a thought signature goes back only to the model
* that signed it, since another one cannot read it.
* step's tool results follow its last result as one user message.
*/
export function toChatMessages(request: AiChatRequest, model?: string): Record<string, unknown>[] {
export function toChatMessages(request: AiChatRequest): Record<string, unknown>[] {
const system = [request.system, request.context].filter(Boolean).join('\n\n');
const out: Record<string, unknown>[] = [{ role: 'system', content: system }];
request.messages.forEach((message, index) => {
out.push(toChatMessage(message, model));
out.push(toChatMessage(message));
const withImages = toolImagesEndingAt(request.messages, index);
if (withImages.length === 0) return;
const content = withImages.flatMap((tool) => [
@@ -64,14 +51,12 @@ function imagePart(image: AiImage): Record<string, unknown> {
return { type: 'image_url', image_url: { url: imageDataUrl(image), detail: image.detail ?? 'high' } };
}
function toChatMessage(message: AiChatMessage, model: string | undefined): Record<string, unknown> {
function toChatMessage(message: AiChatMessage): Record<string, unknown> {
if (message.role === 'user') {
if (!message.images?.length) return { role: 'user', content: message.text };
return { role: 'user', content: [{ type: 'text', text: message.text }, ...message.images.map(imagePart)] };
}
if (message.role === 'tool') return { role: 'tool', tool_call_id: message.toolCallId, content: message.output };
const state = message.providerState;
const signatures = state?.adapter === 'openai-chat' && state.model === model ? state.signatures : {};
return {
role: 'assistant',
content: message.text || null,
@@ -81,32 +66,19 @@ function toChatMessage(message: AiChatMessage, model: string | undefined): Recor
id: call.id,
type: 'function',
function: { name: call.name, arguments: JSON.stringify(call.args) },
...(signatures[call.id] ? { extra_content: { google: { thought_signature: signatures[call.id] } } } : {}),
})),
}
: {}),
};
}
/** Gemini signs a tool call on its first piece; the call has to go back with it or the next request is refused. */
function thoughtSignatureOf(piece: Record<string, unknown>): string {
return asText(asRecord(asRecord(piece.extra_content).google).thought_signature);
}
/** The id a call goes on with; a provider that names none gets one from the call's position. */
function callId(index: number, id: string): string {
return id || `call_${index}`;
}
const FINISH_REASONS: Record<string, AiFinishReason> = { tool_calls: 'tool-calls', length: 'length' };
/**
* The OpenAI-compatible Chat Completions API, streamed: OpenAI itself, Gemini
* (its `/v1beta/openai` endpoint), OpenRouter and an admin's own server. The
* kind decides only what a request may carry: Gemini gets its narrower tool
* schemas, also when an admin enters its endpoint as their own server, and
* OpenAI the prompt cache key, which the others do not know. Gemini's thought
* signatures come back as the turn's provider state.
* schemas, OpenAI the prompt cache key, which the others do not know.
*/
export class OpenAiChatClient implements AiChatClient {
/** OpenRouter searches the web for any model through its web plugin; the others have no search here. */
@@ -114,25 +86,20 @@ export class OpenAiChatClient implements AiChatClient {
return this.kind === 'openrouter';
}
/** The kind whose tool schemas the endpoint takes. */
private readonly schemaKind: AiConnectionKind;
constructor(
readonly kind: AiConnectionKind,
readonly model: string,
private readonly baseUrl: string,
private readonly apiKey: string | null,
private readonly fetcher: AiFetch,
) {
this.schemaKind = kind === 'custom' && isGeminiHost(baseUrl) ? 'gemini' : kind;
}
) {}
async *stream(request: AiChatRequest, signal: AbortSignal): AsyncGenerator<AiChatEvent> {
const body = {
model: this.model,
stream: true,
stream_options: { include_usage: true },
messages: toChatMessages(request, this.model),
messages: toChatMessages(request),
...(request.tools.length > 0
? {
tools: request.tools.map((tool) => ({
@@ -140,7 +107,7 @@ export class OpenAiChatClient implements AiChatClient {
function: {
name: tool.name,
description: tool.description,
parameters: toProviderSchema(tool.parameters, this.schemaKind),
parameters: toProviderSchema(tool.parameters, this.kind),
},
})),
tool_choice: 'auto',
@@ -186,22 +153,10 @@ export class OpenAiChatClient implements AiChatClient {
cachedInputTokens: turn.cachedInputTokens,
outputTokens: turn.outputTokens,
};
const signatures = turn.signatures();
if (Object.keys(signatures).length > 0) {
yield { type: 'provider-state', state: { adapter: 'openai-chat', model: this.model, signatures } };
}
yield { type: 'finish', reason: turn.finishReason() };
}
}
/** A tool call while it streams in: its id, name and arguments so far, and Gemini's signature. */
interface PendingCall {
id: string;
name: string;
args: string;
signature: string;
}
/**
* What one streamed Chat Completions turn has said so far. A tool call
* arrives in pieces keyed by its index (its id and name in the first, the
@@ -213,7 +168,7 @@ class ChatTurn {
outputTokens = 0;
reason: string | null = null;
done = false;
private readonly pending = new Map<number, PendingCall>();
private readonly pending = new Map<number, { id: string; name: string; args: string }>();
private readonly cited = new Set<string>();
private fresh: AiSource[] = [];
@@ -244,11 +199,10 @@ class ChatTurn {
const piece = asRecord(raw);
const index = typeof piece.index === 'number' ? piece.index : position;
const fn = asRecord(piece.function);
const call = this.pending.get(index) ?? { id: '', name: '', args: '', signature: '' };
const call = this.pending.get(index) ?? { id: '', name: '', args: '' };
call.id ||= asText(piece.id);
call.name ||= asText(fn.name);
call.args += asText(fn.arguments);
call.signature ||= thoughtSignatureOf(piece);
this.pending.set(index, call);
});
return asText(delta.content);
@@ -262,24 +216,10 @@ class ChatTurn {
}
calls(): AiToolCall[] {
return this.named().map(([index, call]) => ({
id: callId(index, call.id),
name: call.name,
args: parseToolArgs(call.args),
}));
}
/** The thought signature of each signed call, by the id the call goes back with. */
signatures(): Record<string, string> {
return Object.fromEntries(
this.named()
.filter(([, call]) => call.signature)
.map(([index, call]) => [callId(index, call.id), call.signature]),
);
}
private named(): [number, PendingCall][] {
return [...this.pending.entries()].sort(([a], [b]) => a - b).filter(([, call]) => call.name);
return [...this.pending.entries()]
.sort(([a], [b]) => a - b)
.filter(([, call]) => call.name)
.map(([index, call]) => ({ id: call.id || `call_${index}`, name: call.name, args: parseToolArgs(call.args) }));
}
finishReason(): AiFinishReason {
@@ -59,21 +59,11 @@ export async function refusalOf(kind: AiConnectionKind, res: Response): Promise<
// refusal the provider's own code already explains (a plan that does not cover it) keeps that code.
const image = namesImageInput(res.status, body) && providerFailure(kind, res.status, named).code === 'generic';
const code = image ? IMAGE_INPUT_REFUSED : named;
// Status, code and the refused field's path only: an error message can quote the request, and the
// request holds the trip. The path alone says which part of a replayed conversation the provider refused.
const param = refusedParamOf(body);
logWarn(
`[ai] ${kind} refused the request: HTTP ${res.status}${code ? ` (${code})` : ''}${param ? ` at ${param}` : ''}`,
);
// Status and code only: an error body can quote the request, and the request holds the trip.
logWarn(`[ai] ${kind} refused the request: HTTP ${res.status}${code ? ` (${code})` : ''}`);
return providerFailure(kind, res.status, code);
}
/** The field an error body names as refused (OpenAI's `param`, such as `input[12]`), when it is a bare path. */
function refusedParamOf(body: unknown): string | undefined {
const param = asRecord(asRecord(body).error).param;
return typeof param === 'string' && /^[\w.[\]-]{1,80}$/.test(param) ? param : undefined;
}
/** One SSE data payload as a JSON object; null for anything else (a `[DONE]`, a keepalive). */
export function parseEventData(data: string): Record<string, unknown> | null {
try {
+38 -22
View File
@@ -9,7 +9,6 @@ import { fitToSize } from './assistant-output';
import {
ADD_FOUND_PLACE_TOOL,
APPROVAL_TTL_MS,
ASSISTANT_MODEL_CALLED_SERVER_TOOLS,
ASSISTANT_SERVER_TOOLS,
IMPORT_BOOKING_TOOL,
IMPORT_TOUR_FILE_TOOL,
@@ -191,6 +190,13 @@ function earlierCut(text: string, earlier: boolean): string {
: text;
}
/**
* The model's own calls on attached files: told after the step that made
* them, so the history ends on the user's side when the user's decision on
* them goes on.
*/
const ATTACHED_FILE_TOOLS: ReadonlySet<string> = new Set([READ_ATTACHED_FILE_TOOL, KEEP_ATTACHED_FILE_TOOL]);
/** What became of keeping an attached file in Files, as its stored part says. */
function keptFileLine(part: ToolCallPart): string {
const file = quoted(part.args.name);
@@ -228,31 +234,30 @@ function attachedReadLine(
}
/**
* The answer a replayed call gets: what became of an attached file (the text
* of a file the user let it read, the first time the history holds it, `told`
* keeping track; `texts` are the shared files' texts by attachment id), "not
* applied" for a held change that never ran, the stored result otherwise.
* What the model is told of its reads of attached files in one step: the
* text of each file the user let it read, the first time the history holds
* it (`told` keeps track), and what became of a read the user did not allow.
* `texts` are the shared files' texts, by attachment id; one no longer there
* was swept with its file.
*/
function replayedOutput(
part: ToolCallPart,
function attachedReadNote(
calls: readonly ToolCallPart[],
texts: ReadonlyMap<string, string>,
told: Set<string>,
earlier: boolean,
): string {
if (part.toolName === KEEP_ATTACHED_FILE_TOOL) return keptFileLine(part);
if (part.toolName === READ_ATTACHED_FILE_TOOL) return attachedReadLine(part, texts, told, earlier);
if (part.result === undefined) return NOT_APPLIED;
return outputText(earlier ? fitToSize(part.result, EARLIER_TOOL_OUTPUT_CHARS) : part.result);
return calls
.map((part) =>
part.toolName === KEEP_ATTACHED_FILE_TOOL ? keptFileLine(part) : attachedReadLine(part, texts, told, earlier),
)
.join('\n\n');
}
/**
* The thread as the provider reads it, oldest first, from its first user
* message on. A step's tool calls are followed by their answers; a held
* change that was never applied answers that it was not, since every call
* must be answered. That includes the server's tools the model called itself
* (a trip note, an attached file): the providers that replay their own record
* of a step refuse it with a call left unanswered, and every later message of
* the thread with it. `providerIds` maps a held call's own id to the id the
* must be answered. `providerIds` maps a held call's own id to the id the
* provider gave it.
*/
export function toChatHistory(
@@ -277,31 +282,42 @@ export function toChatHistory(
(part): part is Extract<AssistantMessagePart, { type: 'tool-call' }> =>
part.type === 'tool-call' && part.toolName !== ASSISTANT_WEB_SEARCH_STEP,
);
const serverStep = (part: ToolCallPart) =>
ASSISTANT_SERVER_TOOLS.has(part.toolName) && !ASSISTANT_MODEL_CALLED_SERVER_TOOLS.has(part.toolName);
const reads = allCalls.filter((part) => ATTACHED_FILE_TOOLS.has(part.toolName));
const serverCalls = allCalls.filter(
(part) => ASSISTANT_SERVER_TOOLS.has(part.toolName) && !ATTACHED_FILE_TOOLS.has(part.toolName),
);
// Told as the user's side of the conversation: the history then ends on it
// when a decision on these bookings starts the next answer.
const note = serverStepsNote(allCalls.filter(serverStep));
if (note) history.push({ role: 'user', text: note });
const calls = allCalls.filter((part) => !serverStep(part));
if (serverCalls.length > 0) history.push({ role: 'user', text: serverStepsNote(serverCalls) });
const calls = allCalls.filter((part) => !ASSISTANT_SERVER_TOOLS.has(part.toolName));
const toolCalls: AiToolCall[] = calls.map((part) => ({
id: providerIds.get(part.toolCallId) ?? part.toolCallId,
name: part.toolName,
args: part.args,
}));
const text = textOf(parts);
if (!text && toolCalls.length === 0) return;
// Told after the step that read the files, so the history ends on the user's side when a consent goes on.
const readNote = reads.length > 0 ? attachedReadNote(reads, attachedTexts, told, earlier) : '';
if (!text && toolCalls.length === 0) {
if (readNote) history.push({ role: 'user', text: readNote });
return;
}
const providerState = decodeJson(ASSISTANT_PROVIDER_STATE, row.provider_state, `message ${row.id}`);
history.push({ role: 'assistant', text, toolCalls, providerState });
calls.forEach((part, index) => {
const output =
part.result === undefined
? NOT_APPLIED
: outputText(earlier ? fitToSize(part.result, EARLIER_TOOL_OUTPUT_CHARS) : part.result);
history.push({
role: 'tool',
toolCallId: toolCalls[index].id,
name: part.toolName,
output: replayedOutput(part, attachedTexts, told, earlier),
output,
...(part.isError ? { isError: true } : {}),
});
});
if (readNote) history.push({ role: 'user', text: readNote });
});
return history;
}
@@ -255,18 +255,6 @@ export const ASSISTANT_SERVER_TOOLS: ReadonlySet<string> = new Set([
...Object.keys(ASSISTANT_SERVER_WRITE_TOOLS),
]);
/**
* The server's tools the model calls itself. TREK answers them, but they are
* calls in the provider's own record of the step, so a replayed step has to
* answer them like any other; the rest of the server's tools are steps the
* server took, which the provider never saw.
*/
export const ASSISTANT_MODEL_CALLED_SERVER_TOOLS: ReadonlySet<string> = new Set([
REMEMBER_TOOL,
READ_ATTACHED_FILE_TOOL,
KEEP_ATTACHED_FILE_TOOL,
]);
/** The largest file the assistant reads. */
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
@@ -195,7 +195,7 @@ const DESCRIBERS: Readonly<Record<string, Describer>> = {
const dayId = id(args, 'dayId');
const rows = new Rows()
.add('day', dayLabel(ctx, dayId))
.add('content', text(args.time))
.add('time', text(args.time))
.add('icon', text(args.icon))
.add('color', text(args.color));
return { subject: text(args.text), rows, dayId, placeIds: [] };
@@ -203,8 +203,8 @@ const DESCRIBERS: Readonly<Record<string, Describer>> = {
update_day_note: (ctx, args) => {
const note = ctx.card.notes.get(id(args, 'noteId') ?? 0);
const rows = new Rows();
if (has(args, 'text')) rows.change('title', note?.text ?? null, text(args.text));
if (has(args, 'time')) rows.change('content', note?.time ?? null, text(args.time));
if (has(args, 'text')) rows.change('text', note?.text ?? null, text(args.text));
if (has(args, 'time')) rows.change('time', note?.time ?? null, text(args.time));
if (has(args, 'icon')) rows.change('icon', note?.icon ?? null, text(args.icon));
if (has(args, 'color')) rows.change('color', note?.color ?? null, text(args.color));
return { subject: note?.text ?? text(args.text), rows, dayId: id(args, 'dayId'), placeIds: [] };
+13 -27
View File
@@ -11,13 +11,7 @@ import {
} from '../../nest-mcp';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { DayNotesService } from './day-notes.service';
import {
dayNoteCreateRequestSchema,
dayNoteUpdateRequestSchema,
idSchema,
NOTE_COLORS,
type NoteColor,
} from '@trek/shared';
import { idSchema, NOTE_COLORS, type NoteColor } from '@trek/shared';
import { z } from 'zod';
@@ -29,18 +23,6 @@ import { z } from 'zod';
*/
const noteColorSchema = z.enum(NOTE_COLORS);
/**
* The note's two text fields carry their names from the time a note was a line
* with a clock time: `text` is the heading and `time` the Markdown body under it.
* The REST contract holds the bounds, so a note written in the planner can always
* be edited here; the descriptions are what tells a model which field is which.
*/
const NOTE_TITLE = 'The note title, shown as its heading in the day plan. Keep it short';
const NOTE_BODY =
'The note body shown under the title: details, links, reminders, Markdown allowed. ' +
'Despite the field name this is the content, not a clock time';
const noteTitleSchema = dayNoteCreateRequestSchema.shape.text.describe(NOTE_TITLE);
function parseId(value: string | string[]): number | null {
const n = Number(Array.isArray(value) ? value[0] : value);
return Number.isInteger(n) && n > 0 ? n : null;
@@ -70,12 +52,12 @@ export class DayNotesMcp {
@Tool({
name: 'create_day_note',
description: 'Add a note to a specific day in a trip: a title in `text` and its body in `time`.',
description: 'Add a note to a specific day in a trip.',
inputSchema: {
tripId: idSchema,
dayId: idSchema,
text: noteTitleSchema,
time: dayNoteCreateRequestSchema.shape.time.describe(NOTE_BODY),
text: z.string().min(1).max(500),
time: z.string().max(250).optional().describe('Time label (e.g. "09:00" or "Morning")'),
icon: z.string().max(64).optional().describe('Emoji icon for the note'),
color: noteColorSchema
.nullable()
@@ -104,7 +86,7 @@ export class DayNotesMcp {
tripId: number;
dayId: number;
text: string;
time?: string | null;
time?: string;
icon?: string;
color?: NoteColor | null;
sort_order?: number;
@@ -122,14 +104,18 @@ export class DayNotesMcp {
@Tool({
name: 'update_day_note',
description: 'Edit an existing note on a specific day: its title is `text`, its body `time`.',
description: 'Edit an existing note on a specific day.',
inputSchema: {
tripId: idSchema,
dayId: idSchema,
noteId: idSchema,
// The create bound, so an update cannot blank the title the planner requires.
text: noteTitleSchema.optional(),
time: dayNoteUpdateRequestSchema.shape.time.describe(`${NOTE_BODY}, or null to clear it`),
text: z.string().min(1).max(500).optional(),
time: z
.string()
.max(250)
.nullable()
.optional()
.describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
icon: z.string().max(64).optional().describe('Emoji icon for the note'),
color: noteColorSchema
.nullable()
+2 -1
View File
@@ -1,6 +1,7 @@
import {
bookSpreadPhotoIds,
compareCodeUnits,
MAX_SPREADS,
type BookDesignPlanRow,
type BookDesignSkipped,
type BookDocument,
@@ -175,7 +176,7 @@ export function applyDesignDraft(
if (at < 0 || !samePage(spreads[at], base.get(row.spreadId))) return skip(row.spreadId, 'changed_since');
spreads[at] = page;
} else {
if (at >= 0) return skip(row.spreadId, 'changed_since');
if (at >= 0 || spreads.length >= MAX_SPREADS) return skip(row.spreadId, 'changed_since');
spreads.splice(insertAt(spreads, draft.plan, i, page), 0, page);
}
applied.push(row.spreadId);
@@ -178,7 +178,7 @@ export class JourneyBookDesignService {
baseVersion: draft.baseVersion,
expiresAt: new Date(draft.expiresAt).toISOString(),
document: draft.doc,
plan: draft.plan.map(planRowOut),
plan: draft.plan.slice(0, BOOK_DESIGN_LIMITS.selection).map(planRowOut),
selection: draft.selection,
};
}
@@ -376,7 +376,7 @@ function answerOf(draft: BookDesignDraft): BookDesignAnswer {
expires_at: new Date(draft.expiresAt).toISOString(),
plan: rows,
more_rows: draft.plan.length - rows.length,
selected: draft.selection,
selected: draft.selection.slice(0, BOOK_DESIGN_LIMITS.selection),
style: draft.style,
photos: {
used: draft.usedPhotos,
@@ -3,7 +3,6 @@ import type { PreparedGpxPlace } from '../place-import/place-import.types';
import { PlacesService } from '../places/places.service';
import { readTourFile } from './tour-file-import.helpers';
import { gpxOfLine, splitGpxTrackPieces } from './tour-file-split.helpers';
import { tracksToAdopt } from './tour-track-adoption';
import { computeTourMetrics, parseRouteGeometry } from './tours.helpers';
import { Injectable } from '@nestjs/common';
import { TOUR_GPX_TEXT_MAX, type TourFileLeftOut } from '@trek/shared';
@@ -16,10 +15,7 @@ export interface TourFileTrack {
point_count: number;
distance_km: number;
has_heights: boolean;
/**
* A track already in the trip under the same name and start, which the import would skip. A track
* from before Tours under the same name is none: the import takes it over as a tour (#2810).
*/
/** A track already in the trip under the same name and start, which the import would skip. */
duplicate: boolean;
}
@@ -59,9 +55,7 @@ export class TourFileReadService {
async read(tripId: number, buffer: Buffer, fileName: string): Promise<TourFileTracks> {
const { rows, leftOut } = await readTourFile(this.placeImport, buffer, fileName);
if (rows.length === 0) return { tracks: [], whole: false };
const found = await this.places.gpxDuplicates(String(tripId), rows);
const adopt = tracksToAdopt(rows, await this.places.list(String(tripId), {}));
const duplicates = found.map((duplicate, index) => duplicate && !adopt.has(index));
const duplicates = await this.places.gpxDuplicates(String(tripId), rows);
const extra = leftOut ? { left_out: leftOut } : {};
if (!isGpxName(fileName)) {
const tracks = rows.map((row, index) =>
@@ -11,7 +11,6 @@ import { haversineKm } from '../common/geo';
import { localParts, resolveTimeZone } from '../common/timezoneService';
import type { EndpointInput } from '../reservations/reservations.service';
import { deriveTransitStats, type TransitItinerary, type TransitLeg } from './transit.helpers';
import type { TransitProvider } from '@trek/shared';
import { z } from 'zod';
@@ -206,12 +205,10 @@ function timezoneAt(lat: number, lng: number): string {
return timezone;
}
/** `provider` is the backend the itinerary came from, recorded with the journey. */
export function buildTransitReservationParts(
from: TransitPlaceInput,
to: TransitPlaceInput,
itinerary: TransitItinerary,
provider: TransitProvider,
) {
// The same stop appears up to three times below (transfer endpoint + the
// adjacent legs' metadata) — memoise the tz lookup per coordinate pair.
@@ -279,7 +276,7 @@ export function buildTransitReservationParts(
const stats = deriveTransitStats(itinerary.startTime, itinerary.endTime, itinerary.legs, itinerary.transfers);
const metadata = {
transit: {
provider,
provider: 'transitous',
duration: stats.duration,
transfers: stats.transfers,
walk_seconds: stats.walkSeconds,
+3 -5
View File
@@ -110,7 +110,7 @@ export class TransitMcp {
@Tool({
name: 'search_transit_routes',
description:
'Search scheduled public-transit routes between two coordinates, via whichever backend the instance is configured for. Returns itineraries that can be passed unchanged to create_transit_journey, and `provider`, the backend that answered. `dropped` counts provider itineraries that failed validation and are therefore absent from the results — a non-zero value means the provider offered routes this tool could not represent.',
'Search scheduled public-transit routes between two coordinates, via whichever backend the instance is configured for. Returns itineraries that can be passed unchanged to create_transit_journey. `dropped` counts provider itineraries that failed validation and are therefore absent from the results — a non-zero value means the provider offered routes this tool could not represent.',
inputSchema: {
from: transitPlaceInput,
to: transitPlaceInput,
@@ -169,7 +169,7 @@ export class TransitMcp {
// A rejected itinerary is provider data we could not vouch for, but dropping it
// silently is indistinguishable from "no routes exist" — report the count so the
// caller knows the difference.
return ok({ itineraries, dropped: result.itineraries.length - itineraries.length, provider: result.provider });
return ok({ itineraries, dropped: result.itineraries.length - itineraries.length });
} catch (err) {
return errorResult(err, 'Transit route search failed.');
}
@@ -228,11 +228,9 @@ export class TransitMcp {
isError: true,
};
}
// The itinerary came from search_transit_routes, which the active backend answered.
const provider = await this.transit.activeProvider(ctx.userId);
let reservationParts: ReturnType<typeof buildTransitReservationParts>;
try {
reservationParts = buildTransitReservationParts(from, to, cleaned, provider);
reservationParts = buildTransitReservationParts(from, to, cleaned);
} catch (err) {
return errorResult(err, 'Unable to resolve the transit journey timezones.');
}
+2 -7
View File
@@ -159,11 +159,6 @@ export class TransitService {
@Inject(transitConfig.KEY) private readonly transitEnv: ConfigType<typeof transitConfig>,
) {}
/** The backend that answers this user's searches: Google where it is set up and keyed, Transitous otherwise. */
async activeProvider(userId = 0): Promise<TransitProvider> {
return (await this.google.isActive(userId)) ? 'google' : 'transitous';
}
/**
* Station/place search for the from/to pickers. `near` biases results.
*
@@ -180,7 +175,7 @@ export class TransitService {
const text = (query || '').trim();
// Answered before either backend is consulted, so it reports the one that
// WOULD have been asked rather than claiming nobody was.
const provider = await this.activeProvider(userId);
const provider: TransitProvider = (await this.google.isActive(userId)) ? 'google' : 'transitous';
if (text.length < 2) return { results: [], provider };
if (text.length > 200) {
const e = new Error('Query too long') as Error & { status: number };
@@ -264,7 +259,7 @@ export class TransitService {
// After validation on purpose: whichever backend answers, the caller is held
// to the same coordinate/mode/transfer contract and gets the same 400s.
if ((await this.activeProvider(userId)) === 'google') {
if (await this.google.isActive(userId)) {
return { ...(await this.google.plan(q, language, userId)), provider: 'google' };
}
+2 -2
View File
@@ -59,7 +59,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
titleKey: 'system_notice.release_notes.headline',
bodyKey: 'system_notice.release_notes.intro',
release: {
version: '5.0.1',
version: '5.0.0',
eyebrowKey: 'system_notice.release_notes.eyebrow',
headlineKey: 'system_notice.release_notes.headline',
introKey: 'system_notice.release_notes.intro',
@@ -95,7 +95,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
footnoteKey: 'system_notice.release_notes.footnote',
notes: {
labelKey: 'system_notice.release_notes.notes_label',
href: 'https://github.com/liketrek/TREK/releases/tag/v5.0.1',
href: 'https://github.com/liketrek/TREK/releases/tag/v5.0.0',
},
note: {
eyebrowKey: 'system_notice.release_notes.note_eyebrow',
+7 -31
View File
@@ -100,16 +100,6 @@ async function answer(req: IncomingMessage, res: ServerResponse): Promise<void>
let nestApp: INestApplication;
let orm: MikroORM;
let fake: HttpServer;
let adminId: number;
/** The instance connection, pointed at the fake under `path`: `/v1`, or nothing as an Ollama address is often entered. */
function connectAt(path: string) {
const { port } = fake.address() as AddressInfo;
return request(nestApp.getHttpServer())
.put('/api/admin/ai/connection')
.set('Cookie', sessionCookie(adminId))
.send({ kind: 'custom', baseUrl: `http://127.0.0.1:${port}${path}`, model: 'fake-model' });
}
beforeAll(async () => {
fake = createServer((req, res) => {
@@ -120,8 +110,13 @@ beforeAll(async () => {
orm = nestApp.get(MikroORM);
await setAddonEnabled(orm, 'ai_assistant', true);
await setAddonEnabled(orm, 'atlas', true);
adminId = (await makeUser(orm, { role: 'admin' })).user.id;
expect((await connectAt('/v1')).status).toBe(200);
const adminId = (await makeUser(orm, { role: 'admin' })).user.id;
const { port } = fake.address() as AddressInfo;
const res = await request(nestApp.getHttpServer())
.put('/api/admin/ai/connection')
.set('Cookie', sessionCookie(adminId))
.send({ kind: 'custom', baseUrl: `http://127.0.0.1:${port}/v1`, model: 'fake-model' });
expect(res.status).toBe(200);
});
beforeEach(async () => {
@@ -347,23 +342,4 @@ describe('Home assistant e2e', () => {
expect(await countRows(orm, AssistantThreads, { id: homeThread })).toBe(0);
expect(await countRows(orm, AssistantThreads, { id: tripThread })).toBe(1);
});
it('ASTHE2E-007: an Ollama address entered without /v1 chats at /v1/chat/completions, where Ollama answers', async () => {
const h = await ownerWithTrips();
expect((await connectAt('')).status).toBe(200);
try {
provider.script = [say('Hello from Ollama.')];
const res = await post(`${BASE}/chat`, h.userId, { ...CTX, threadId: null, text: 'hi' });
expect(res.status).toBe(200);
expect(provider.requests).toHaveLength(1);
expect(
ofType(res.chunks, 'text-delta')
.map((c) => c.delta)
.join(''),
).toBe('Hello from Ollama.');
expect(ofType(res.chunks, 'error')).toEqual([]);
} finally {
expect((await connectAt('/v1')).status).toBe(200);
}
});
});
-76
View File
@@ -99,43 +99,6 @@ describe('Tool: create_day_note', () => {
});
});
it('describes text as the title and time as the body, as the planner shows them', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const { tools } = await h.client.listTools();
for (const name of ['create_day_note', 'update_day_note']) {
const props = tools.find((t) => t.name === name)?.inputSchema.properties as Record<string, unknown>;
// The nullable body may come out as an anyOf, so read the property whole.
const time = JSON.stringify(props.time);
expect(JSON.stringify(props.text)).toMatch(/title/i);
expect(time).toMatch(/body/i);
expect(time).not.toMatch(/time label/i);
expect(time).toContain('"maxLength":2000');
}
});
});
it('takes a body of 2000 characters, the bound the planner allows, and refuses one more', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const body = 'b'.repeat(2000);
const result = await h.client.callTool({
name: 'create_day_note',
arguments: { tripId: trip.id, dayId: day.id, text: 'Museum day', time: body },
});
const data = parseToolResult(result) as { note: { id: number } };
expect((await dayNote(data.note.id)).time).toBe(body);
const tooLong = await h.client.callTool({
name: 'create_day_note',
arguments: { tripId: trip.id, dayId: day.id, text: 'Museum day', time: `${body}b` },
});
expect(tooLong.isError).toBe(true);
expect(await countRows(orm, DayNotes, { day: day.id })).toBe(1);
});
});
it('defaults icon to 📝', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -257,45 +220,6 @@ describe('Tool: update_day_note', () => {
});
});
it('edits a note whose body is longer than 250 characters without shortening it', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const longBody = 'Opening hours and the ticket links. '.repeat(20);
const note = createDayNote(testDb, day.id, trip.id, { text: 'Museum day', time: longBody });
await withHarness(user.id, async (h) => {
const renamed = await h.client.callTool({
name: 'update_day_note',
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: 'Museums' },
});
expect(renamed.isError).toBeFalsy();
const longer = `${longBody}Bring the pass.`;
const rewritten = await h.client.callTool({
name: 'update_day_note',
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, time: longer },
});
expect(rewritten.isError).toBeFalsy();
const row = await dayNote(note.id);
expect(row.text).toBe('Museums');
expect(row.time).toBe(longer);
});
});
it('refuses an empty title on update and keeps the stored one', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const note = createDayNote(testDb, day.id, trip.id, { text: 'Keep me' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_day_note',
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: '' },
});
expect(result.isError).toBe(true);
expect((await dayNote(note.id)).text).toBe('Keep me');
});
});
it('trims text whitespace', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -201,7 +201,6 @@ describe('MCP transit tools', () => {
expect(routes.itineraries[0].legs[0].from.name).toBe('Namba');
expect(routes.itineraries[0].legs[1].to.name).toBe('Umeda');
expect(routes.itineraries).toHaveLength(1);
expect(routes.provider).toBe('transitous');
expect(planMock).toHaveBeenCalledWith(expect.objectContaining({ modes: 'SUBWAY' }), undefined, user.id);
const invalidNear = await harness.client.callTool({
@@ -245,27 +244,6 @@ describe('MCP transit tools', () => {
});
});
it('records Google as the provider of a journey planned while Google answers searches (#2850)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-04' });
const day = await dayOf(trip.id, '2026-12-03');
const active = vi.spyOn(TransitService.prototype, 'activeProvider').mockResolvedValue('google');
try {
await withHarness(user.id, ['reservations:write'], async (harness) => {
const result = parseToolResult(
await harness.client.callTool({
name: 'create_transit_journey',
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary },
}),
) as { reservation: { metadata: string } };
expect(JSON.parse(result.reservation.metadata).transit.provider).toBe('google');
});
expect(active).toHaveBeenCalledWith(user.id);
} finally {
active.mockRestore();
}
});
it('rejects dateless, mismatched, and out-of-range journey dates', async () => {
const { user } = createUser(testDb);
const datelessTrip = createTrip(testDb, user.id);
@@ -7,7 +7,6 @@ import {
aiHostOf,
aiRefusal,
openrouterOAuthAvailable,
ownServerChatBase,
providerRefusal,
} from '../../../../src/nest/ai-connections/ai-connections.constants';
import { InMemoryAiRefreshLock } from '../../../../src/nest/ai-connections/ai-refresh-lock';
@@ -146,16 +145,6 @@ describe('constants', () => {
expect(aiHostOf('custom', null)).toBe('');
});
it('AIHLP-032b: a bare own server address chats under /v1, an address with a path as entered', () => {
expect(ownServerChatBase('http://ollama.lan:11434')).toBe('http://ollama.lan:11434/v1');
expect(ownServerChatBase(' http://ollama.lan:11434/ ')).toBe('http://ollama.lan:11434/v1');
expect(ownServerChatBase('http://ollama.lan:11434/v1/')).toBe('http://ollama.lan:11434/v1');
expect(ownServerChatBase('https://api.groq.com/openai/v1')).toBe('https://api.groq.com/openai/v1');
expect(ownServerChatBase('https://llm.example.org/proxy')).toBe('https://llm.example.org/proxy');
expect(ownServerChatBase('not a url')).toBe('not a url');
expect(ownServerChatBase(null)).toBe('');
});
it('AIHLP-033: OpenRouter sign-in is offered only on an https app URL', () => {
vi.stubEnv('APP_URL', 'https://trip.example.org');
expect(openrouterOAuthAvailable()).toBe(true);
@@ -28,12 +28,6 @@ import { toProviderSchema, type AiFetch } from '../../../../src/nest/ai-connecti
import { describe, expect, it, vi } from 'vitest';
const { logWarn } = vi.hoisted(() => ({ logWarn: vi.fn() }));
vi.mock('../../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../../src/nest/audit/audit-log.logger')>()),
logWarn,
}));
/** An SSE body from whole events, cut every `every` characters so no event arrives in one piece. */
function sseBody(events: unknown[], every = 7): ReadableStream<Uint8Array> {
const text = events.map((e) => `data: ${typeof e === 'string' ? e : JSON.stringify(e)}\n\n`).join('');
@@ -151,40 +145,6 @@ describe('provider errors', () => {
expect(errorCodeOf(null)).toBeUndefined();
expect(errorCodeOf({})).toBeUndefined();
});
it('PERR-004: a refusal is logged with the path of the field it names, never with its message (#2845)', async () => {
const refused = (param: unknown) =>
failure(
new OpenAiResponsesClient(
'm',
async () => 'at',
fetchAnswering(
jsonResponse(400, {
error: {
type: 'invalid_request_error',
code: null,
message: 'No tool output found for function call call_note. Dinner in Rome at 20:00.',
param,
},
}),
).fetcher,
).stream(REQUEST, signal()),
);
logWarn.mockClear();
expect(await refused('input[3]')).toMatchObject({ code: 'generic', status: 400 });
expect(logWarn).toHaveBeenCalledWith(
'[ai] chatgpt refused the request: HTTP 400 (invalid_request_error) at input[3]',
);
logWarn.mockClear();
await refused('input[3] Dinner in Rome');
await refused(null);
expect(logWarn.mock.calls.map(([line]) => line)).toEqual([
'[ai] chatgpt refused the request: HTTP 400 (invalid_request_error)',
'[ai] chatgpt refused the request: HTTP 400 (invalid_request_error)',
]);
});
});
describe('toProviderSchema', () => {
@@ -529,125 +489,6 @@ describe('OpenAiChatClient', () => {
await failure(client(streamResponse([{ choices: [{ delta: { content: 'half' } }] }])).stream(REQUEST, signal())),
).toMatchObject({ providerCode: 'stream_cut' });
});
// Gemini 3 signs the first call of a step on its first piece, the way its Chat Completions endpoint streams it.
const SIGNED = [
{
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: 'function-call-1',
type: 'function',
function: { name: 'create_place', arguments: '{"name":"Nijo"}' },
extra_content: { google: { thought_signature: 'sig-A' } },
},
],
},
},
],
},
{
choices: [
{
delta: {
tool_calls: [{ index: 1, function: { name: 'list_places', arguments: '{}' } }],
},
finish_reason: 'tool_calls',
},
],
},
'[DONE]',
];
it('OACH-006: a signed call comes back as the provider state, keyed by the id it goes on with', async () => {
const events = await run(
new OpenAiChatClient(
'gemini',
'gemini-3-flash',
'https://g',
'k',
fetchAnswering(streamResponse(SIGNED)).fetcher,
).stream(REQUEST, signal()),
);
expect(events.filter((event) => event.type === 'tool-call')).toEqual([
{ type: 'tool-call', call: { id: 'function-call-1', name: 'create_place', args: { name: 'Nijo' } } },
{ type: 'tool-call', call: { id: 'call_1', name: 'list_places', args: {} } },
]);
expect(events.slice(-2)).toEqual([
{
type: 'provider-state',
state: { adapter: 'openai-chat', model: 'gemini-3-flash', signatures: { 'function-call-1': 'sig-A' } },
},
{ type: 'finish', reason: 'tool-calls' },
]);
// An unsigned turn keeps no state: nothing is stored for a provider that signs nothing.
const plain = await run(
new OpenAiChatClient('openai', 'm', 'https://x', 'k', fetchAnswering(streamResponse(STREAM)).fetcher).stream(
REQUEST,
signal(),
),
);
expect(plain.some((event) => event.type === 'provider-state')).toBe(false);
});
it('OACH-007: the signature goes back with its call to the model that signed it, and to no other', async () => {
const signed: AiChatRequest = {
...REQUEST,
messages: [
{ role: 'user', text: 'Add Nijo' },
{
role: 'assistant',
text: '',
toolCalls: [
{ id: 'function-call-1', name: 'create_place', args: { name: 'Nijo' } },
{ id: 'call_1', name: 'list_places', args: {} },
],
providerState: {
adapter: 'openai-chat',
model: 'gemini-3-flash',
signatures: { 'function-call-1': 'sig-A' },
},
},
{ role: 'tool', toolCallId: 'function-call-1', name: 'create_place', output: '{"id":7}' },
{ role: 'tool', toolCallId: 'call_1', name: 'list_places', output: '[]' },
],
};
const http = fetchAnswering(streamResponse(['[DONE]']));
await run(
new OpenAiChatClient('gemini', 'gemini-3-flash', 'https://g', 'k', http.fetcher).stream(signed, signal()),
);
expect(dig(http.body(), 'messages', 2, 'tool_calls')).toEqual([
{
id: 'function-call-1',
type: 'function',
function: { name: 'create_place', arguments: '{"name":"Nijo"}' },
extra_content: { google: { thought_signature: 'sig-A' } },
},
{ id: 'call_1', type: 'function', function: { name: 'list_places', arguments: '{}' } },
]);
// A thread that moved on to another model sends the calls plain: no other model reads the signature.
for (const messages of [toChatMessages(signed, 'gpt-5-mini'), toChatMessages(signed)]) {
expect(JSON.stringify(messages)).not.toContain('extra_content');
}
});
it('OACH-008: an own server on Gemini’s endpoint gets Gemini’s narrower schemas, any other the full ones', async () => {
const parameters = async (baseUrl: string) => {
const http = fetchAnswering(streamResponse(['[DONE]']));
await run(new OpenAiChatClient('custom', 'm', baseUrl, 'k', http.fetcher).stream(REQUEST, signal()));
return dig(http.body(), 'tools', 0, 'function', 'parameters');
};
expect(await parameters('https://generativelanguage.googleapis.com/v1beta/openai')).toEqual(
toProviderSchema(TOOL.parameters, 'gemini'),
);
expect(await parameters('http://ollama:11434/v1')).toEqual(toProviderSchema(TOOL.parameters, 'custom'));
expect(await parameters('not a url')).toEqual(toProviderSchema(TOOL.parameters, 'custom'));
});
});
describe('OpenAiResponsesClient', () => {
@@ -6,10 +6,6 @@
*/
import type { AssistantMessageRow } from '../../../../src/db/repositories/AssistantMessages.repository';
import type { AssistantToolCallRow } from '../../../../src/db/repositories/AssistantToolCalls.repository';
import type { AiChatMessage } from '../../../../src/nest/ai-connections/ai-chat-client.interface';
import { toAnthropicMessages } from '../../../../src/nest/ai-connections/providers/anthropic-messages.client';
import { toChatMessages } from '../../../../src/nest/ai-connections/providers/openai-chat.client';
import { toResponsesInput } from '../../../../src/nest/ai-connections/providers/openai-responses.client';
import {
messageIdOf,
outputText,
@@ -253,7 +249,7 @@ describe('toChatHistory', () => {
]);
});
it('ASTHIS-005d: the text of a file the user let the model read answers its read, once, cut in earlier turns', () => {
it('ASTHIS-005d: the text of a file the user let the model read comes after the step, once, cut in earlier turns', () => {
const id = '0b7c2f0e-6f6d-4a8e-9b1a-3d2c1e0f9a87';
type CallPart = Extract<AssistantMessagePart, { type: 'tool-call' }>;
const read = (toolCallId: string, extra: Partial<CallPart>): AssistantMessagePart => ({
@@ -274,27 +270,20 @@ describe('toChatHistory', () => {
row('user', [{ type: 'text', text: 'Thanks' }]),
];
const history = toChatHistory(rows, new Map(), new Map([[id, long]]));
expect(history.map((message) => message.role)).toEqual(['user', 'assistant', 'tool', 'assistant', 'tool', 'user']);
expect(history[1]).toMatchObject({
role: 'assistant',
text: 'I need to read it first.',
toolCalls: [{ id: 'r1', name: 'read_attached_file' }],
});
expect(history[2]).toMatchObject({ role: 'tool', toolCallId: 'r1' });
const told = (history[2] as { output: string }).output;
expect(history.map((message) => message.role)).toEqual(['user', 'assistant', 'user', 'user', 'user']);
expect(history[1]).toMatchObject({ role: 'assistant', text: 'I need to read it first.', toolCalls: [] });
const told = (history[2] as { text: string }).text;
expect(told).toContain('The user let you read the attached file "AirBnB Buchung.pdf".');
expect(told).toContain('Casa Lisboa');
// An earlier turn's text is cut so it does not crowd out the conversation.
expect(told.length).toBeLessThan(long.length);
expect(told.endsWith('[...]')).toBe(true);
expect(history[4]).toMatchObject({
role: 'tool',
toolCallId: 'r2',
output: 'You read the attached file "AirBnB Buchung.pdf" again; its text is above.',
});
expect((history[3] as { text: string }).text).toBe(
'You read the attached file "AirBnB Buchung.pdf" again; its text is above.',
);
// The latest turn keeps the whole text.
const latest = toChatHistory(rows.slice(0, 2), new Map(), new Map([[id, long]]));
expect((latest[2] as { output: string }).output).toContain(long);
expect((latest[2] as { text: string }).text).toContain(long);
});
it('ASTHIS-005e: a read the user did not allow, one still waiting, a refused one and a swept file say so', () => {
@@ -314,21 +303,21 @@ describe('toChatHistory', () => {
]),
],
new Map(),
).map((message) => (message.role === 'tool' ? message.output : null));
expect(step({ approval: { id: 'r', approved: false } })[2]).toBe(
).map((message) => (message as { text: string }).text);
expect(step({ approval: { id: 'r', approved: false } })[1]).toBe(
'The user did not let you read the attached file "a.pdf".',
);
expect(step({ approval: { id: 'r' } })[2]).toContain("waits for the user's agreement; you have not read it.");
expect(step({ approval: { id: 'r', resolution: 'expired' } })[2]).toContain('you have not read it');
expect(step({ result: 'No such attachment in this conversation.', isError: true })[2]).toBe(
expect(step({ approval: { id: 'r' } })[1]).toContain("waits for the user's agreement; you have not read it.");
expect(step({ approval: { id: 'r', resolution: 'expired' } })[1]).toContain('you have not read it');
expect(step({ result: 'No such attachment in this conversation.', isError: true })[1]).toBe(
'Reading the attached file "a.pdf" failed: No such attachment in this conversation.',
);
expect(step({ result: { read: true } })[2]).toBe(
expect(step({ result: { read: true } })[1]).toBe(
'The attached file "a.pdf" is no longer kept; you cannot read it again.',
);
});
it('ASTHIS-005f: keeping an attached file is answered with what became of it', () => {
it('ASTHIS-005f: keeping an attached file is told after its step, whatever became of it', () => {
const keep = (extra: Partial<Extract<AssistantMessagePart, { type: 'tool-call' }>>) =>
toChatHistory(
[
@@ -347,12 +336,11 @@ describe('toChatHistory', () => {
new Map(),
);
const kept = keep({ approval: { id: 'k', approved: true }, result: { file: { id: 31 } } });
expect(kept.map((message) => message.role)).toEqual(['user', 'assistant', 'tool']);
expect(kept[2]).toMatchObject({
toolCallId: 'k',
output: 'The attached file "a.pdf" is in the trip\'s Files now (file id 31).',
});
const lastOf = (extra: Parameters<typeof keep>[0]) => (keep(extra).at(-1) as { output: string }).output;
expect(kept.map((message) => message.role)).toEqual(['user', 'assistant', 'user']);
expect((kept[2] as { text: string }).text).toBe(
'The attached file "a.pdf" is in the trip\'s Files now (file id 31).',
);
const lastOf = (extra: Parameters<typeof keep>[0]) => (keep(extra).at(-1) as { text: string }).text;
expect(
lastOf({ approval: { id: 'k', approved: true }, result: 'The file could not be kept.', isError: true }),
).toBe('The attached file "a.pdf" could not be kept in Files: The file could not be kept.');
@@ -460,168 +448,6 @@ describe('toChatHistory', () => {
});
});
describe('toChatHistory: calls the model made of the server tools (#2845)', () => {
/**
* The thread of #2845: a step that kept a trip note on the way (it runs at
* once), then a step whose changes the user applied, both with the
* provider's own record of the step stored beside them.
*/
function appliedAfterANote(
noteState: Record<string, unknown>,
changeState: Record<string, unknown>,
): AiChatMessage[] {
const rows = [
row('user', [{ type: 'text', text: 'Dinner at 20:00 on day 2, and remember we like late dinners' }]),
row(
'assistant',
[
{
type: 'tool-call',
toolCallId: 'call_note',
toolName: 'remember_for_trip',
args: { note: 'Likes late dinners' },
result: { notes: 'Likes late dinners' },
},
],
{ provider_state: JSON.stringify(noteState) },
),
row(
'assistant',
[
{ type: 'text', text: 'Shall I move it?' },
{
type: 'tool-call',
toolCallId: 'held-1',
toolName: 'update_assignment_time',
args: { assignmentId: 7, place_time: '20:00' },
result: { assignment: { id: 7 } },
approval: { id: 'held-1', approved: true },
},
],
{ provider_state: JSON.stringify(changeState) },
),
];
return toChatHistory(rows, new Map([['held-1', 'call_move']]));
}
it('ASTHIS-015: the Responses API gets an answer for every call it made, a kept note included', () => {
const history = appliedAfterANote(
{
adapter: 'openai-responses',
items: [
{ type: 'reasoning', id: 'rs_1', encrypted_content: 'x' },
{ type: 'function_call', call_id: 'call_note', name: 'remember_for_trip', arguments: '{}' },
],
},
{
adapter: 'openai-responses',
items: [
{ type: 'reasoning', id: 'rs_2', encrypted_content: 'y' },
{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Shall I move it?' }] },
{ type: 'function_call', call_id: 'call_move', name: 'update_assignment_time', arguments: '{}' },
],
},
);
const input = toResponsesInput({ system: '', messages: history, tools: [] });
const made = input.filter((item) => item.type === 'function_call').map((item) => item.call_id);
const answered = input.filter((item) => item.type === 'function_call_output').map((item) => item.call_id);
expect(made).toEqual(['call_note', 'call_move']);
expect(answered).toEqual(['call_note', 'call_move']);
for (const id of made) {
const call = input.findIndex((item) => item.type === 'function_call' && item.call_id === id);
const output = input.findIndex((item) => item.type === 'function_call_output' && item.call_id === id);
expect(output).toBeGreaterThan(call);
}
expect(input.find((item) => item.type === 'function_call_output' && item.call_id === 'call_note')).toMatchObject({
output: '{"notes":"Likes late dinners"}',
});
// Nothing empty goes out as the user's: a kept note is no step of the server to tell.
const userTexts = input
.filter((item) => item.type === 'message' && item.role === 'user')
.flatMap((item) => (item.content as Array<{ text?: string }>).map((part) => part.text ?? ''));
expect(userTexts).toEqual(['Dinner at 20:00 on day 2, and remember we like late dinners']);
});
it('ASTHIS-016: Anthropic gets a tool_result for every tool_use it replays, a kept note included', () => {
const history = appliedAfterANote(
{
adapter: 'anthropic-messages',
blocks: [
{ type: 'thinking', thinking: 'note it', signature: 's1' },
{ type: 'tool_use', id: 'call_note', name: 'remember_for_trip', input: {} },
],
},
{
adapter: 'anthropic-messages',
blocks: [
{ type: 'thinking', thinking: 'move it', signature: 's2' },
{ type: 'text', text: 'Shall I move it?' },
{ type: 'tool_use', id: 'call_move', name: 'update_assignment_time', input: {} },
],
},
);
const messages = toAnthropicMessages(history);
const uses = messages.flatMap((message) => message.content).filter((block) => block.type === 'tool_use');
expect(uses.map((block) => block.id)).toEqual(['call_note', 'call_move']);
messages.forEach((message, index) => {
const ids = message.content.filter((block) => block.type === 'tool_use').map((block) => block.id);
if (ids.length === 0) return;
const next = messages[index + 1];
expect(next?.role).toBe('user');
const results = next.content.filter((block) => block.type === 'tool_result').map((block) => block.tool_use_id);
expect(results).toEqual(ids);
});
expect(messages.flatMap((message) => message.content).some((block) => block.text === '')).toBe(false);
});
it('ASTHIS-018: Gemini gets every stored call back with its thought signature, an applied change included', () => {
const state = (id: string, signature: string) => ({
adapter: 'openai-chat',
model: 'gemini-3-flash',
signatures: { [id]: signature },
});
type SentCall = { id: string; extra_content?: { google: { thought_signature: string } } };
const history = appliedAfterANote(state('call_note', 'sig-note'), state('call_move', 'sig-move'));
const sent = toChatMessages({ system: '', messages: history, tools: [] }, 'gemini-3-flash')
.flatMap((message) => (message.tool_calls ?? []) as SentCall[])
.map((call) => [call.id, call.extra_content?.google.thought_signature]);
expect(sent).toEqual([
['call_note', 'sig-note'],
['call_move', 'sig-move'],
]);
});
it('ASTHIS-017: a step that only kept a note is the model call it was, answered with what was kept', () => {
const history = toChatHistory(
[
row('user', [{ type: 'text', text: 'Remember the ferry leaves at 7' }]),
row('assistant', [
{
type: 'tool-call',
toolCallId: 'n1',
toolName: 'remember_for_trip',
args: { note: 'Ferry at 7' },
result: { notes: 'Ferry at 7' },
},
]),
row('assistant', [{ type: 'text', text: 'Noted.' }]),
],
new Map(),
);
expect(history).toEqual([
{ role: 'user', text: 'Remember the ferry leaves at 7' },
{
role: 'assistant',
text: '',
toolCalls: [{ id: 'n1', name: 'remember_for_trip', args: { note: 'Ferry at 7' } }],
providerState: null,
},
{ role: 'tool', toolCallId: 'n1', name: 'remember_for_trip', output: '{"notes":"Ferry at 7"}' },
{ role: 'assistant', text: 'Noted.', toolCalls: [], providerState: null },
]);
});
});
describe('toThreadMessages', () => {
it('ASTHIS-010: the steps of one answer are one message under the first row id', () => {
nextId = 10;
@@ -142,7 +142,7 @@ describe('describeToolCall', () => {
});
expect(describe_('update_day_note', { dayId: 31, noteId: 41, time: null })).toMatchObject({
subject: 'Rail pass',
rows: [{ op: 'change', labelKey: 'content', before: '09:00', after: '' }],
rows: [{ op: 'change', labelKey: 'time', before: '09:00', after: '' }],
});
expect(describe_('create_day_note', { dayId: 33, text: 'Laundry', icon: '🧺' }).rows).toEqual([
{ op: 'add', labelKey: 'day', before: null, after: 'Spare day' },
@@ -19,6 +19,7 @@ import {
bookPhotoElementSchema,
bookShapeElementSchema,
bookTextElementSchema,
MAX_SPREADS,
type BookDesignPlanRow,
type BookDocument,
type BookSpread,
@@ -309,15 +310,15 @@ describe('applyDesignDraft', () => {
expect(out.doc.spreads).toEqual(base.spreads);
});
it('JBAPPLY-014: a book of hundreds of pages still takes a new page', () => {
const inner = Array.from({ length: 400 }, (_, i) => page(`i${i}`, 'inner'));
const long = book([page('c', 'cover'), ...inner]);
const draft: DraftToApply = { base: long, doc: book(pages('n')), plan: [row('n', 'insert')], selection: ['n'] };
it('JBAPPLY-014: a book at the page limit takes no new page, the page named as changed since', () => {
const inner = Array.from({ length: MAX_SPREADS - 1 }, (_, i) => page(`i${i}`, 'inner'));
const full = book([page('c', 'cover'), ...inner]);
const draft: DraftToApply = { base: full, doc: book(pages('n')), plan: [row('n', 'insert')], selection: ['n'] };
const out = applyDesignDraft(long, draft, new Set());
const out = applyDesignDraft(full, draft, new Set());
expect([out.applied, out.skipped]).toEqual([['n'], []]);
expect(out.doc.spreads).toHaveLength(402);
expect(out.skipped).toEqual([{ spreadId: 'n', reason: 'changed_since' }]);
expect(out.doc.spreads).toHaveLength(MAX_SPREADS);
});
it('JBAPPLY-015: a page whose letters are filled with a photo gone since stays out, as a frame of it would', () => {
@@ -6,12 +6,10 @@
* line is written as GPX.
*/
import { PlaceImportService } from '../../../src/nest/place-import/place-import.service';
import { PlacesService } from '../../../src/nest/places/places.service';
import { TourFileReadService } from '../../../src/nest/tours/tour-file-read.service';
import { gpxOfLine, splitGpxTracks } from '../../../src/nest/tours/tour-file-split.helpers';
import { TourFilesModule } from '../../../src/nest/tours/tour-files.module';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { makePlace } from '../../helpers/factories/places';
import { makeTrip } from '../../helpers/factories/trips';
import { makeUser } from '../../helpers/factories/users';
import { createTestModule, type TestModule } from '../../helpers/test-module';
@@ -20,7 +18,7 @@ import { TOUR_GPX_TEXT_MAX } from '@trek/shared';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
// TOUR-FILE-READ-001 to TOUR-FILE-READ-009
// TOUR-FILE-READ-001 to TOUR-FILE-READ-008
const db = createSnapshotTestDb();
let t: TestModule;
@@ -132,25 +130,6 @@ describe('TourFileReadService', () => {
});
});
it('TOUR-FILE-READ-009: a track from before Tours under the same name is proposed, for the import to take over (#2810)', async () => {
const trip = await tripId();
// 4.x stored a GPX track as a plain place: a line, no tours row.
await makePlace(t.get(MikroORM), trip, {
name: 'Saturday',
lat: 47.5,
lng: 11.1,
route_geometry: '[[47.5,11.1,800],[47.51,11.1,1000]]',
});
const rows = t.get(PlaceImportService).readGpx(Buffer.from(TWO_TRACKS, 'utf8'), { importTracks: true });
expect((await t.get(PlacesService).gpxDuplicates(String(trip), rows)).some(Boolean)).toBe(true);
const read = await files.read(trip, Buffer.from(TWO_TRACKS, 'utf8'), 'weekend.gpx');
expect(read.tracks.map((track) => [track.name, track.duplicate])).toEqual([
['Saturday', false],
['Sunday', false],
]);
});
it('TOUR-FILE-READ-008: a track longer than one import takes is named but carries no text', async () => {
const points = Array.from(
{ length: 20_000 },
@@ -311,7 +311,7 @@ describe('buildTransitReservationParts', () => {
],
};
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary, 'transitous');
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary);
expect(endpoints).toEqual([
{
@@ -407,7 +407,7 @@ describe('buildTransitReservationParts', () => {
const kyoto = { name: 'Kyoto', lat: 35.0116, lng: 135.7681 };
const itinerary = twoLegs();
itinerary.legs = [{ ...itinerary.legs[0], to: stop('Umeda', 34.702, 135.496, null) }, itinerary.legs[1]];
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary, 'transitous');
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary);
expect(endpoints[1]).toEqual({
role: 'stop',
sequence: 1,
@@ -423,18 +423,14 @@ describe('buildTransitReservationParts', () => {
});
it('TRANSIT-ITIN-020: an unresolvable endpoint timezone throws with the coordinates in the message', () => {
expect(() =>
buildTransitReservationParts({ name: 'Nowhere', lat: NaN, lng: 135.501 }, umeda, singleLeg(), 'transitous'),
).toThrow('Unable to resolve timezone for NaN,135.501.');
expect(() => buildTransitReservationParts({ name: 'Nowhere', lat: NaN, lng: 135.501 }, umeda, singleLeg())).toThrow(
'Unable to resolve timezone for NaN,135.501.',
);
});
it('TRANSIT-ITIN-021: an unconvertible itinerary time throws with the ISO string and timezone', () => {
expect(() =>
buildTransitReservationParts(namba, umeda, singleLeg({ startTime: 'not-a-date' }), 'transitous'),
).toThrow('Unable to convert not-a-date to local time in Asia/Tokyo.');
});
it('TRANSIT-ITIN-022: the journey records the backend its itinerary came from (#2850)', () => {
expect(buildTransitReservationParts(namba, umeda, singleLeg(), 'google').metadata.transit.provider).toBe('google');
expect(() => buildTransitReservationParts(namba, umeda, singleLeg({ startTime: 'not-a-date' }))).toThrow(
'Unable to convert not-a-date to local time in Asia/Tokyo.',
);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/shared",
"version": "5.0.1",
"version": "5.0.0",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
-3
View File
@@ -154,9 +154,6 @@ export const ASSISTANT_CHANGE_LABEL_KEYS = [
'currency',
'stopType',
'text',
// A day note's heading and its body; `text` and `time` stay for rows already stored.
'title',
'content',
'icon',
'color',
'position',
+8 -4
View File
@@ -5,7 +5,7 @@ import {
bookPageOpSchema,
bookSpreadIdSchema,
} from '../../book/book-ops.schema';
import { bookSpreadSchema, MAX_BOOK_TITLE } from '../../book/book.schema';
import { bookSpreadSchema, MAX_BOOK_TITLE, MAX_SPREADS } from '../../book/book.schema';
import { idSchema } from '../../common/primitives.schema';
import { cardCountSchema, cardText } from './card-parts';
@@ -33,10 +33,14 @@ export const BOOK_PAGE_CARD_CHANGES = ['new', 'changed', 'moved', 'willDelete']
export const bookCardPageSchema = z.object({
spreadId: bookSpreadIdSchema,
/** Where it stands in the book, the cover at 0. */
index: z.number().int().min(0),
index: z
.number()
.int()
.min(0)
.max(MAX_SPREADS - 1),
role: bookSpreadSchema.shape.role.unwrap(),
/** Its place among the double pages, from 1; null for a single page (the covers, the first and the last). */
number: z.number().int().min(1).nullable(),
number: z.number().int().min(1).max(MAX_SPREADS).nullable(),
/** Photos placed on it. */
photos: cardCountSchema,
});
@@ -81,7 +85,7 @@ export const bookPageCardSchema = z.object({
/** How many pages the change adds or deletes; for a new book, its pages. */
count: cardCountSchema,
/** Where a moved page lands among the double pages, from 1. */
movedTo: z.number().int().min(1).nullable(),
movedTo: z.number().int().min(1).max(MAX_SPREADS).nullable(),
/**
* `edit_book_page`'s operations, to draw the page after where the card has
* no `after` or the Studio's book moved on from `baseVersion`; empty for
+2 -2
View File
@@ -1,6 +1,6 @@
import { bookElementId } from '../book-ids';
import { blankBook, blankSpread } from '../book-pages';
import { MAX_BOOK_TITLE, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
import { MAX_BOOK_TITLE, MAX_SPREADS, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
import type { JourneyStats } from '../journey-stats.schema';
import {
STATIONS_PER_PAGE,
@@ -155,7 +155,7 @@ export function buildBook(input: AutoInput): BookDocument {
version: 1,
title: input.title.slice(0, MAX_BOOK_TITLE),
page: withFirstPage(input.page),
spreads,
spreads: spreads.slice(0, MAX_SPREADS),
};
}
@@ -108,21 +108,6 @@ describe('the auto layout moved from the Studio', () => {
expect(new Set(ids).size).toBe(ids.length);
});
it('AUTOPAR-005: lays out every entry of a long journey, the last page and the back cover after them', () => {
const fx = fixtures.A as Fixture;
const entries = Array.from({ length: 40 }, (_, round) =>
fx.input.entries.map((e) => ({ ...e, id: e.id + 100000 * (round + 1) })),
).flat();
let n = 0;
const doc = buildBook({ ...fx.input, entries, newId: (p) => `${p}-c${++n}` });
expect(doc.spreads.length).toBeGreaterThan(300);
expect(doc.spreads.slice(-2).map((s) => s.role)).toEqual(['last', 'back']);
const laid = new Set(doc.spreads.map((s) => s.entryId));
const withPages = new Set(buildBook({ ...fx.input, newId: (p) => `${p}-d${++n}` }).spreads.map((s) => s.entryId));
const expected = entries.filter((e) => withPages.has(e.id % 100000));
expect(expected.every((e) => laid.has(e.id))).toBe(true);
});
it('AUTOPAR-004: counts the day of an entry in calendar days, a change to summer time between', () => {
const stats: JourneyStats = {
journeyId: 1,
@@ -1,4 +1,5 @@
import {
BOOK_DESIGN_LIMITS,
applyBookDesignRequestSchema,
bookDesignAnswerSchema,
bookDesignApplyResultSchema,
@@ -69,9 +70,10 @@ describe('the design tools', () => {
expect(removeBookLayoutRequestSchema.safeParse({ layoutId: 'ly-1', expectedVersion: 4 }).success).toBe(true);
});
it('BKDTOOL-004: the selection is a closed list of page ids, as long as the book it picks from', () => {
const ids = Array.from({ length: 1200 }, (_, i) => `sp-${i}`);
it('BKDTOOL-004: the selection is a closed list of page ids, as long as a book and its drops', () => {
const ids = Array.from({ length: BOOK_DESIGN_LIMITS.selection }, (_, i) => `sp-${i}`);
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: ids }).success).toBe(true);
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: [...ids, 'sp-x'] }).success).toBe(false);
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: ['sp 1'] }).success).toBe(false);
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: [], all: true }).success).toBe(false);
});
+15 -9
View File
@@ -7,7 +7,7 @@ import {
bookWriteResultSchema,
editBookPageArgs,
} from './book-ops.schema';
import { BOOK_FONTS_IDS, MAX_SPREAD_ELEMENTS, bookDocumentSchema } from './book.schema';
import { BOOK_FONTS_IDS, MAX_SPREADS, MAX_SPREAD_ELEMENTS, bookDocumentSchema } from './book.schema';
import { BOOK_DESIGN_STYLES, bookDesignDirectivesSchema } from './design/design-directives.schema';
import { BOOK_DESIGN_ACTIONS } from './design/design-merge';
import { BOOK_DESIGN_BEATS } from './design/design-pacing';
@@ -34,6 +34,8 @@ export const BOOK_DESIGN_LIMITS = {
notes: 20,
/** Pages of the book an apply answers with; every applied id is still named. */
pages: 24,
/** Pages a selection names at most: every page of a book and every page it drops. */
selection: MAX_SPREADS * 2,
/** Revisions a journey keeps, the newest first. */
revisions: 5,
} as const;
@@ -80,7 +82,11 @@ export type RestoreBookLayoutRequest = z.infer<typeof restoreBookLayoutRequestSc
/** One page of a design's plan, as an answer and the draft route carry it. */
export const bookDesignPlanRowSchema = z.object({
spreadId: bookSpreadIdSchema,
index: z.number().int().min(0),
index: z
.number()
.int()
.min(0)
.max(MAX_SPREADS * 2),
action: z.enum(BOOK_DESIGN_ACTIONS),
beat: z.enum(BOOK_DESIGN_BEATS),
chapter: z.string().max(80).nullable(),
@@ -115,7 +121,7 @@ export const bookDesignAnswerSchema = z.object({
/** Plan rows past those listed. */
more_rows: z.number().int().nonnegative(),
/** The pages apply_book_design writes unless the person takes some out. */
selected: z.array(bookSpreadIdSchema),
selected: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
style: bookDesignStyleSchema,
photos: z.object({
used: z.number().int().nonnegative(),
@@ -153,19 +159,19 @@ export const bookDesignApplyResultSchema = bookWriteResultSchema.extend({
design_id: bookDesignIdSchema,
/** The revision that takes the apply back; null when the journey had no book before it. */
revision_id: idSchema.nullable(),
applied: z.array(bookSpreadIdSchema),
skipped: z.array(bookDesignSkippedSchema),
applied: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
skipped: z.array(bookDesignSkippedSchema).max(BOOK_DESIGN_LIMITS.selection),
});
export type BookDesignApplyResult = z.infer<typeof bookDesignApplyResultSchema>;
/** PUT /api/journeys/:journeyId/book-designs/:designId/selection: the pages the apply writes. */
export const bookDesignSelectionRequestSchema = z.strictObject({
spreadIds: z.array(bookSpreadIdSchema),
spreadIds: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
});
export type BookDesignSelectionRequest = z.infer<typeof bookDesignSelectionRequestSchema>;
export const bookDesignSelectionResponseSchema = z.object({
selection: z.array(bookSpreadIdSchema),
selection: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
});
export type BookDesignSelectionResponse = z.infer<typeof bookDesignSelectionResponseSchema>;
@@ -175,7 +181,7 @@ export const bookDesignDraftResponseSchema = z.object({
baseVersion: version.nullable(),
expiresAt: z.string(),
document: bookDocumentSchema,
plan: z.array(bookDesignPlanRowSchema),
selection: z.array(bookSpreadIdSchema),
plan: z.array(bookDesignPlanRowSchema).max(BOOK_DESIGN_LIMITS.selection),
selection: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
});
export type BookDesignDraftResponse = z.infer<typeof bookDesignDraftResponseSchema>;
+4
View File
@@ -21,6 +21,7 @@ import { bookElementPhotoIds, checkRestorablePhotos } from './book-photo-refs';
import { bookLayoutFromSpread, nextBookLayoutName } from './book-saved-layouts';
import {
MAX_BOOK_LAYOUTS,
MAX_SPREADS,
bookLayoutSchema,
type BookDocument,
type BookLayout,
@@ -106,6 +107,9 @@ export function duplicateBookPage(doc: BookDocument, args: unknown, ctx: BookOps
if (!canEditSpread(source)) {
throw failOp('protected_spread', `${source.id} is the ${source.role} page and cannot be copied`, null);
}
if (doc.spreads.length + 1 > MAX_SPREADS) {
throw failOp('too_many_spreads', `a book holds at most ${MAX_SPREADS} pages`, null);
}
const fresh = <E extends { id: string; kind: string }>(e: E): E => ({ ...e, id: newId(e.kind.charAt(0)) });
const copy = checkedSpread(
{ ...source, id: newId('sp'), elements: source.elements.map(fresh), parked: source.parked.map(fresh) },
+1
View File
@@ -32,6 +32,7 @@ export const BOOK_OP_ERROR_CODES = [
'layout_not_found',
'layout_role',
'too_many_elements',
'too_many_spreads',
'parked_full',
'empty_frame',
'caption_missing',
+12 -3
View File
@@ -20,6 +20,7 @@ import { bookElementIdSchema, bookGridIdSchema, bookSpreadIdSchema } from './boo
import { bookPaintInSchema } from './book-paint.schema';
import {
MAX_BOOK_TITLE,
MAX_SPREADS,
MAX_SPREAD_ELEMENTS,
bookBadgeElementSchema,
bookCountriesElementSchema,
@@ -272,7 +273,11 @@ export const addBookPagesArgs = {
};
export const moveBookPageArgs = {
spreadId: bookSpreadIdSchema,
toIndex: z.number().int().min(0),
toIndex: z
.number()
.int()
.min(0)
.max(MAX_SPREADS - 1),
expectedVersion,
};
export const deleteBookPagesArgs = {
@@ -297,7 +302,11 @@ export const createBookArgs = {
* it goes back when it is gone, a page still in the book is replaced where it is.
*/
export const bookPreviousSpreadSchema = z.object({
index: z.number().int().min(0),
index: z
.number()
.int()
.min(0)
.max(MAX_SPREADS - 1),
spread: bookSpreadSchema,
});
export type BookPreviousSpread = z.infer<typeof bookPreviousSpreadSchema>;
@@ -616,7 +625,7 @@ export const bookWriteResultSchema = z.object({
dropped: z.array(z.string()).max(MAX_SPREAD_ELEMENTS * 2),
previous_spreads: z.array(bookPreviousSpreadSchema).optional(),
/** The pages the write touched as it left them, for an undo that checks only those (`expectedPages`). */
page_digests: z.array(bookPageDigestSchema).optional(),
page_digests: z.array(bookPageDigestSchema).max(MAX_SPREADS).optional(),
from_index: z.number().int().nonnegative().optional(),
/** From `set_book_setup`: the setup as it was, to set it back. */
previous_setup: bookPageSetupSchema.optional(),
+3 -5
View File
@@ -2,6 +2,7 @@ import type { BookEditResult } from './book-ops-core';
import { addBookPages, deleteBookPages, moveBookPage, restoreBookPage, setBookCover } from './book-page-ops';
import { blankBook, blankSpread, bookPageSetupForPreset, canEditSpread, innerSpreadBounds } from './book-pages';
import {
MAX_SPREADS,
bookDocumentSchema,
bookPageSetupSchema,
bookPhotoElementSchema,
@@ -128,13 +129,10 @@ describe('add, move and delete', () => {
expect(refused(stray)).toBe('entry_not_found');
});
it('BOOKPG-007: refuses an unknown page and more than ten pages, and grows a book of any length', () => {
it('BOOKPG-007: refuses an unknown page, more than ten pages, and a book at its page cap', () => {
expect(refused(addBookPages(book(1), { afterSpreadId: 'sp-nope' }))).toBe('spread_not_found');
expect(refused(addBookPages(book(1), { afterSpreadId: 'sp-in1', count: 11 }))).toBe('invalid_op');
const long = book(400);
const grown = ok(addBookPages(long, { afterSpreadId: 'sp-in400', count: 10 }, { newId }));
expect(grown.spreads).toHaveLength(long.spreads.length + 10);
expect(grown.spreads.slice(-2).map((s) => s.role)).toEqual(['last', 'back']);
expect(refused(addBookPages(book(MAX_SPREADS - 4), { afterSpreadId: 'sp-in1' }))).toBe('too_many_spreads');
});
it('BOOKPG-008: moves an inner spread within the inner range', () => {
+7
View File
@@ -23,6 +23,7 @@ import {
import { blankSpread, canEditSpread, innerSpreadBounds } from './book-pages';
import { bookSpreadPhotoIds, checkRestorablePhotos } from './book-photo-refs';
import {
MAX_SPREADS,
bookSpreadSchema,
type BookDocument,
type BookElement,
@@ -56,6 +57,9 @@ export function addBookPages(doc: BookDocument, args: unknown, ctx: BookOpsConte
return caught(() => {
const after = indexOf(doc, afterSpreadId);
if (entryId !== undefined) journeyEntry(entryId, ctx, null);
if (doc.spreads.length + count > MAX_SPREADS) {
throw failOp('too_many_spreads', `a book holds at most ${MAX_SPREADS} pages`, null);
}
const { first, limit } = innerSpreadBounds(doc.spreads);
const at = Math.min(Math.max(after + 1, first), limit);
const added = Array.from({ length: count }, () =>
@@ -180,6 +184,9 @@ function putBack(doc: BookDocument, spread: BookSpread, index: number): BookDocu
if (spread.role !== 'inner' && doc.spreads.some((s) => s.role === spread.role)) {
throw failOp('protected_spread', `the book already has its ${spread.role} page`, null);
}
if (doc.spreads.length >= MAX_SPREADS) {
throw failOp('too_many_spreads', `a book holds at most ${MAX_SPREADS} pages`, null);
}
const { first, limit } = innerSpreadBounds(doc.spreads);
const to = spread.role === 'inner' ? Math.min(Math.max(index, first), limit) : Math.min(index, doc.spreads.length);
return { ...doc, spreads: [...doc.spreads.slice(0, to), spread, ...doc.spreads.slice(to)] };
+4 -4
View File
@@ -20,10 +20,10 @@ import { z } from 'zod';
*
* ── Why the whole document ───────────────────────────────────────────────
*
* Not a patch. A book is a few kilobytes of JSON a page, a few megabytes for
* the longest journey's, and a patch protocol would need an ordering
* guarantee, a merge rule per field, and a way to recover when a client
* misses one. Sending the document costs bandwidth that autosave already
* Not a patch. A book is a few hundred kilobytes of JSON at most — the schema
* caps it at 150 spreads of 60 elements — and a patch protocol would need an
* ordering guarantee, a merge rule per field, and a way to recover when a
* client misses one. Sending the document costs bandwidth that autosave already
* amortises and removes an entire class of bug.
*/
+2 -5
View File
@@ -65,6 +65,7 @@ const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb');
* `MAX_SPREAD_ELEMENTS` belongs to this set and is declared with the spread it
* bounds, where the salvage pass reads it too.
*/
export const MAX_SPREADS = 150;
export const MAX_BOOK_TITLE = 200;
export const MAX_TEXT_LENGTH = 8000;
/** Codes on a map or a country page, and the names printed beside them. */
@@ -1121,11 +1122,7 @@ const bookDocumentObject = z.object({
schemaRev: z.number().int().min(1).optional().catch(undefined),
title: z.string().max(MAX_BOOK_TITLE).default(''),
page: bookPageSetupSchema.default(() => bookPageSetupSchema.parse({})),
/**
* As many as the journey needs: a long trip's book runs to hundreds of pages,
* and the save route's body limit is the only bound on its size.
*/
spreads: z.array(bookSpreadSchema).default([]),
spreads: z.array(bookSpreadSchema).max(MAX_SPREADS).default([]),
/** The book's own layouts (#2316). Optional so a document from before still reads. */
layouts: z.array(bookLayoutSchema).max(MAX_BOOK_LAYOUTS).optional(),
/** Palette, typefaces and motifs; elements link to it through the fields of theme/book-theme.schema.ts. */
+6 -5
View File
@@ -5,6 +5,7 @@ import type { BookPhotoInfo } from '../book-ops-core';
import { blankBook } from '../book-pages';
import { isStale } from '../book-travel';
import {
MAX_SPREADS,
MAX_SPREAD_ELEMENTS,
bookDocumentSchema,
bookPhotoElementSchema,
@@ -366,10 +367,10 @@ describe('design_book', () => {
expect(spread.notes.some((n) => n.includes('hero-script-word'))).toBe(true);
});
it('BOOKDSN-014: designs every page beside hundreds of kept pages, the closing pages kept', () => {
it('BOOKDSN-014: stays within the spread cap beside many kept pages, the closing pages kept', () => {
const stored = storedBook();
const lockedPage = stored.spreads[2] as BookSpread;
const many = Array.from({ length: 300 }, (_, i): BookSpread => ({
const many = Array.from({ length: MAX_SPREADS - 6 }, (_, i): BookSpread => ({
...lockedPage,
id: `sp-l${i}`,
elements: lockedPage.elements.map((e) => ({ ...e, id: `t-l${i}` })),
@@ -382,10 +383,10 @@ describe('design_book', () => {
for (const style of ['minimal', 'classic'] as const) {
const result = designBook(full, input('rich'), { style });
checkStructure(result);
expect(inner(result.doc).length).toBeGreaterThan(many.length);
expect(result.doc.spreads.length).toBeLessThanOrEqual(MAX_SPREADS);
expect(inner(result.doc).length).toBeLessThanOrEqual(MAX_SPREADS - 4);
expect(many.every((p) => result.doc.spreads.some((s) => s.id === p.id))).toBe(true);
expect(result.doc.spreads.slice(-2).map((s) => s.role)).toEqual(['last', 'back']);
expect(result.notes.some((n) => n.includes('left out'))).toBe(false);
expect(result.notes.length).toBeGreaterThan(0);
}
});
+28 -8
View File
@@ -9,7 +9,7 @@ import type { BookPhotoInfo } from '../book-ops-core';
import type { BookFinding } from '../book-ops.schema';
import { bookPageSetupForPreset } from '../book-pages';
import type { BookTravelContext } from '../book-travel';
import { MAX_BOOK_TITLE, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
import { MAX_BOOK_TITLE, MAX_SPREADS, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
import { pageFindings } from '../describe-spread';
import type { PhotoPickCandidate } from '../photo-pick';
import { isBookFontFamily, mutedOn, readableOn } from '../theme/book-theme';
@@ -25,7 +25,7 @@ import {
import { composeBack, composeCover, composeTitlePage, stationsPerSpread } from './design-framing';
import { composeDesignedInner, type FramingNeeds } from './design-inner';
import { isLivelyBeat } from './design-lively-pacing';
import { mergeDesign, type BookDesignPlanRow, type DesignedSpread } from './design-merge';
import { keepsStored, mergeDesign, type BookDesignPlanRow, type DesignedSpread } from './design-merge';
import { applyPageOverrides } from './design-overrides';
import type { BookDesignBeat } from './design-pacing';
import {
@@ -140,6 +140,23 @@ function classicOnly(d: BookDesignDirectives): boolean {
return d.chapters === 'entry' && d.density === 'balanced';
}
/**
* The designed pages within the spread cap: inner pages past `room` are left
* off the end (the framing pages always stay), and the note says how many.
*/
function withinRoom(designed: readonly DesignedSpread[], room: number, notes: string[]): DesignedSpread[] {
const inner = designed.filter((d) => d.spread.role === 'inner');
const over = inner.length - Math.max(0, room);
if (over <= 0) return [...designed];
const cut = new Set(inner.slice(inner.length - over).map((d) => d.spread.id));
notes.push(`the book holds ${MAX_SPREADS} spreads, so the last ${over} designed pages are left out`);
return designed.filter((d) => !cut.has(d.spread.id));
}
/** Inner spreads the book has room for once the stored pages it keeps are counted. */
const roomFor = (doc: BookDocument | null, keep: BookDesignDirectives['keep']) =>
MAX_SPREADS - 4 - (doc?.spreads ?? []).filter((s) => s.role === 'inner' && keepsStored(s, keep)).length;
/** The travel context the travel elements read: the journey's figures, route, entries and labels. */
function travelOf(input: BookDesignInput, entries: readonly AutoEntry[]): BookTravelContext {
return {
@@ -188,13 +205,14 @@ function classicDesign(
chapter: entries.find((e) => e.id === spread.entryId)?.title ?? null,
findings: pageFindings(spread, page, input.photos),
}));
const merged = mergeDesign(doc, all, directives.keep);
const designed = withinRoom(all, roomFor(doc, directives.keep), notes);
const merged = mergeDesign(doc, designed, directives.keep);
const finalDoc: BookDocument = {
...built,
spreads: merged.spreads,
...(doc?.layouts ? { layouts: doc.layouts } : {}),
};
const residual = all.flatMap((d) => d.findings.filter((f) => f.severity !== 'info'));
const residual = designed.flatMap((d) => d.findings.filter((f) => f.severity !== 'info'));
return {
doc: finalDoc,
plan: merged.plan,
@@ -256,7 +274,7 @@ export function designBook(
notes: [],
art,
};
const needs: FramingNeeds = { perStations: stationsPerSpread(c) };
const needs: FramingNeeds = { room: roomFor(doc, directives.keep), perStations: stationsPerSpread(c) };
const inner = composeDesignedInner(c, placed, choice, input, needs);
const words = {
@@ -529,7 +547,8 @@ function templatedDesign(
raw: BookDesignDirectivesInput,
): BookDesignResult {
const { c, archetype, calm, placed, rest, choice, finish } = templatedSetting(doc, input, raw);
const inner = composeTemplatedInner(c, placed, choice, input, { archetype, calm, rest });
const needs: FramingNeeds = { room: roomFor(doc, c.directives.keep), perStations: stationsPerSpread(c) };
const inner = composeTemplatedInner(c, placed, choice, input, needs, { archetype, calm, rest });
const usedPhotos = [...choice.byEntry.values()].flat();
const designed = templatedPages(c, input, { placed, usedPhotos, inner: inner.spreads, archetype });
const done = designed.map((d): DesignedSpread => ({ ...d, ...finish(d.spread, !!d.template) }));
@@ -550,7 +569,8 @@ interface TemplatedParts {
function templatedResult(doc: BookDocument | null, input: BookDesignInput, parts: TemplatedParts): BookDesignResult {
const { c, finish, placed, notes } = parts;
const { look, directives, page } = c;
const laid = mergeDesign(doc, parts.done, directives.keep);
const designed = withinRoom(parts.done, roomFor(doc, directives.keep), notes);
const laid = mergeDesign(doc, designed, directives.keep);
const built: BookDocument = {
version: 1,
title: (directives.cover?.title ?? input.title).slice(0, MAX_BOOK_TITLE),
@@ -566,7 +586,7 @@ function templatedResult(doc: BookDocument | null, input: BookDesignInput, parts
: { doc: built, plan: laid.plan, notes: [] };
const residual = overrides.length
? redesignedFindings(merged.doc, merged.plan, input.photos)
: parts.done.flatMap((d) => d.findings.filter((f) => f.severity !== 'info'));
: designed.flatMap((d) => d.findings.filter((f) => f.severity !== 'info'));
const judged = briefOf(c);
const brief = judged ? briefFindings(merged.doc, judged, briefScope(merged.plan)) : [];
const used = [...new Set(merged.plan.flatMap((r) => (r.action === 'drop' ? [] : r.photoIds)))];
@@ -1,6 +1,7 @@
import { BOOK_COVER_ARCHETYPES, type BookCoverArchetype } from '../book-archetypes';
import { bookPageOpSchema } from '../book-ops.schema';
import { bookLockupIdSchema } from '../book-text-ops.schema';
import { MAX_SPREADS } from '../book.schema';
import { BOOK_THEME_DECORATIONS, BOOK_THEME_IDS } from '../theme/book-theme.schema';
import { z } from 'zod';
@@ -96,7 +97,12 @@ const overrideTemplateSchema = z
export const bookDesignPageOverrideSchema = z
.strictObject({
index: z.number().int().min(2).describe('The page by its index in the plan design_book answered; the cover is 0.'),
index: z
.number()
.int()
.min(2)
.max(MAX_SPREADS - 1)
.describe('The page by its index in the plan design_book answered; the cover is 0.'),
beat: z
.enum(BOOK_DESIGN_OVERRIDE_BEATS)
.describe(
+6 -3
View File
@@ -41,6 +41,8 @@ import { decorateSpread } from './design-stickers';
*/
export interface FramingNeeds {
/** Inner spreads the book has room for, the kept stored pages taken off. */
room: number;
/** Bare stops one stations spread holds. */
perStations: number;
}
@@ -68,8 +70,9 @@ function framingInner(c: ComposeCtx): FramingKind[] {
return out;
}
/** The most inner spreads the design may make: `pages.max` when the directives set one, otherwise as many as it needs. */
const innerCap = (c: ComposeCtx) => Math.max(0, c.directives.pages?.max ?? Number.POSITIVE_INFINITY);
/** The most inner spreads the design may make: `pages.max`, and never past the room the book has. */
const innerCap = (c: ComposeCtx, needs: FramingNeeds) =>
Math.max(0, Math.min(c.directives.pages?.max ?? needs.room, needs.room));
/**
* The framing pages that fit beside at least one chapter spread: past the cap
@@ -313,7 +316,7 @@ export function composeDesignedInner(
): InnerResult {
const notes: string[] = [];
const leftOut: BookDesignLeftOut[] = [];
const cap = innerCap(c);
const cap = innerCap(c, needs);
const wanted = framingInner(c);
const framing = fittingFraming(wanted, cap);
if (framing.length < wanted.length) {
+1 -1
View File
@@ -46,7 +46,7 @@ const hasLock = (spread: BookSpread) => spread.elements.some((e) => e.locked);
* inner page only while the person keeps locked pages); with `edited` also a
* page made by hand (no entry behind it, not blank).
*/
function keepsStored(spread: BookSpread, keep: BookDesignDirectives['keep']): boolean {
export function keepsStored(spread: BookSpread, keep: BookDesignDirectives['keep']): boolean {
if (spread.role !== 'inner') return hasLock(spread);
if (keep === 'nothing') return false;
if (hasLock(spread)) return true;
@@ -1,228 +1,228 @@
{
"A/fabric-classic/head": "74bb0439e4575ed7",
"A/fabric-classic/plan": "95b55b052b9ff07b",
"A/fabric-classic/spread/0": "36ce28aa969ee78e",
"A/fabric-classic/spread/0": "a4d090166287cbe5",
"A/fabric-classic/spread/1": "aa637b0de2518bf5",
"A/fabric-classic/spread/10": "f6f5a5bfbb97c674",
"A/fabric-classic/spread/11": "c1e942bd73455a86",
"A/fabric-classic/spread/12": "c390827640a1bc65",
"A/fabric-classic/spread/13": "f49a2ec83099a556",
"A/fabric-classic/spread/14": "f13bb412f61a778c",
"A/fabric-classic/spread/15": "7ec17a9b9e9955f8",
"A/fabric-classic/spread/16": "029964006e41bbdb",
"A/fabric-classic/spread/17": "78ae60620ae287a3",
"A/fabric-classic/spread/18": "c73e813888a28cd1",
"A/fabric-classic/spread/19": "d98cc9a891bf2141",
"A/fabric-classic/spread/10": "febf8fa0b4cd3aef",
"A/fabric-classic/spread/11": "26253da4f1f688a0",
"A/fabric-classic/spread/12": "4dbf29b2c63f9231",
"A/fabric-classic/spread/13": "5deb8ece6f27e514",
"A/fabric-classic/spread/14": "db4f2ede14bb6023",
"A/fabric-classic/spread/15": "7d011356ea8fc70f",
"A/fabric-classic/spread/16": "205c5f0f86f90cb4",
"A/fabric-classic/spread/17": "de849a3986a487c1",
"A/fabric-classic/spread/18": "22b9cb0c78e0ff66",
"A/fabric-classic/spread/19": "9df44ff280b76f64",
"A/fabric-classic/spread/2": "8278d3c2507527f9",
"A/fabric-classic/spread/20": "e1dfa3eeb056189b",
"A/fabric-classic/spread/21": "b18be5f5e1696caf",
"A/fabric-classic/spread/22": "98a8cab370aeb8fa",
"A/fabric-classic/spread/20": "972976260c457cd3",
"A/fabric-classic/spread/21": "9102a5fa147693fe",
"A/fabric-classic/spread/22": "0bc9c54f6ee8b3e9",
"A/fabric-classic/spread/23": "b11b5b3fb34a34eb",
"A/fabric-classic/spread/24": "a55189e7bfd3934d",
"A/fabric-classic/spread/3": "2ceb4890ba95c249",
"A/fabric-classic/spread/4": "d0eba1475d8d9f04",
"A/fabric-classic/spread/5": "468dc734d5aae63f",
"A/fabric-classic/spread/6": "776a926951bfdbe5",
"A/fabric-classic/spread/7": "16f3ec7630e52f5d",
"A/fabric-classic/spread/8": "f64487735a6b5be1",
"A/fabric-classic/spread/9": "70dba74ad0223875",
"A/fabric-classic/spread/4": "7743a5784df1d3e5",
"A/fabric-classic/spread/5": "dbbbfb7bedd90da2",
"A/fabric-classic/spread/6": "c36b6c75a4254fad",
"A/fabric-classic/spread/7": "b7694f72f7cbe2bb",
"A/fabric-classic/spread/8": "bdafe534fcbe56f0",
"A/fabric-classic/spread/9": "c8a03b3fbdb60114",
"A/midnight/head": "ce0d82281cbc3e1f",
"A/midnight/plan": "7fde812131c9b570",
"A/midnight/spread/0": "1ea6071685899f8f",
"A/midnight/spread/0": "409beb5fbb1accce",
"A/midnight/spread/1": "d9f5d78659332b45",
"A/midnight/spread/10": "b42ae04363ff591c",
"A/midnight/spread/11": "a7271ec809fb43f3",
"A/midnight/spread/12": "e2a46e3248f9f7f2",
"A/midnight/spread/13": "674814c68bdfc64e",
"A/midnight/spread/14": "9131f08ae5ee964d",
"A/midnight/spread/15": "516667160a8ea844",
"A/midnight/spread/16": "7f572ace66e550eb",
"A/midnight/spread/17": "f75b6c80be848ac6",
"A/midnight/spread/18": "029d7d07ce5cb3c3",
"A/midnight/spread/19": "14c04aa51da497cd",
"A/midnight/spread/10": "53f13a2ec3a3c5f4",
"A/midnight/spread/11": "888c0c66730d32dc",
"A/midnight/spread/12": "8d6c62bc4ee5ed1d",
"A/midnight/spread/13": "fc0673a56f5b4d4b",
"A/midnight/spread/14": "33db0be88fd469a7",
"A/midnight/spread/15": "72105c668088bc4c",
"A/midnight/spread/16": "bd11f4dc18e03108",
"A/midnight/spread/17": "2e7a0f09822c3556",
"A/midnight/spread/18": "f4264c3ee121b8d1",
"A/midnight/spread/19": "4f27a48a457d95bb",
"A/midnight/spread/2": "b00839cd0dc1451b",
"A/midnight/spread/20": "ea8aa8da12cad00a",
"A/midnight/spread/21": "68057d81fd4903b4",
"A/midnight/spread/20": "dff6e523732c8481",
"A/midnight/spread/21": "26d666dde7be6641",
"A/midnight/spread/22": "eba329fd185958b3",
"A/midnight/spread/23": "9664fbd24505a632",
"A/midnight/spread/3": "742dfb4105263101",
"A/midnight/spread/4": "6d89cdaeac29b8b6",
"A/midnight/spread/5": "25f6a66be27c2415",
"A/midnight/spread/6": "14c828e117eb72a4",
"A/midnight/spread/7": "8fac481cb6c1d93d",
"A/midnight/spread/8": "8730ae9ad717bbe5",
"A/midnight/spread/9": "9792ae63b36eff3e",
"A/midnight/spread/4": "ea9613c918951560",
"A/midnight/spread/5": "8aa571b4d51a5234",
"A/midnight/spread/6": "2275359a3c03f457",
"A/midnight/spread/7": "3ef3c265b38598e2",
"A/midnight/spread/8": "3a77eec4f6c9f860",
"A/midnight/spread/9": "5bdff7027bea103c",
"A/riso/head": "7d43d0900fbe0f57",
"A/riso/plan": "f802ad819f1fb262",
"A/riso/spread/0": "50a4cc5fafbfad6e",
"A/riso/spread/0": "1f4502d29eca9017",
"A/riso/spread/1": "8de92788de29277b",
"A/riso/spread/10": "d468165710581556",
"A/riso/spread/11": "1997a6c3ae1c8570",
"A/riso/spread/12": "655767248f707020",
"A/riso/spread/13": "15e7ddcf0d397509",
"A/riso/spread/14": "88cf12b3fa6b6be9",
"A/riso/spread/15": "d2bb88a721cc8320",
"A/riso/spread/10": "2a92b79192575cd0",
"A/riso/spread/11": "5785a9cc59ddd735",
"A/riso/spread/12": "cd4f6004153fe694",
"A/riso/spread/13": "b1f87a92cb7f775c",
"A/riso/spread/14": "873bf5f7a8404fe4",
"A/riso/spread/15": "c1aab0214328ef3b",
"A/riso/spread/16": "d58f70c381d9f6ae",
"A/riso/spread/17": "00caa407cb2a0677",
"A/riso/spread/18": "def2a9dde53f3e26",
"A/riso/spread/19": "3cea577870cdc23d",
"A/riso/spread/18": "ed41aca064456614",
"A/riso/spread/19": "36647f06c594e715",
"A/riso/spread/2": "5c9a93de9e6467d6",
"A/riso/spread/20": "c851d704ccca068e",
"A/riso/spread/21": "be8c5194461daa01",
"A/riso/spread/22": "dcd4a1dfde54cef9",
"A/riso/spread/20": "8518ca61c110fd8a",
"A/riso/spread/21": "d4ba78b2ffd5ae04",
"A/riso/spread/22": "de5116307e12ce61",
"A/riso/spread/23": "a748ada7a028818e",
"A/riso/spread/24": "9abf456403732463",
"A/riso/spread/3": "20a199a47535dea7",
"A/riso/spread/4": "1b46e4d88f0a0db5",
"A/riso/spread/5": "dd07d5451021f0f5",
"A/riso/spread/6": "ae0f0bbad2f029e3",
"A/riso/spread/7": "5029fc77bc6a7bca",
"A/riso/spread/8": "6eecfd5fd8319fe6",
"A/riso/spread/9": "3ba63e382035e668",
"A/riso/spread/4": "93b84be6e48097b5",
"A/riso/spread/5": "6d5ae8e80c2f0d21",
"A/riso/spread/6": "e7f5c928785be48d",
"A/riso/spread/7": "b672fd04b61ea7b9",
"A/riso/spread/8": "d9e84bbad744e450",
"A/riso/spread/9": "017d695be27dc988",
"B/fabric-classic/head": "6af0d11dd09eae78",
"B/fabric-classic/plan": "cae85517611abbca",
"B/fabric-classic/spread/0": "9da4b641b0f81264",
"B/fabric-classic/spread/0": "87325aa216fe9c6a",
"B/fabric-classic/spread/1": "b3f8e9c5043f8302",
"B/fabric-classic/spread/2": "5951c4e8d72b4b3f",
"B/fabric-classic/spread/3": "bdb01dc38f94e0d0",
"B/fabric-classic/spread/4": "4e949ebeb4098598",
"B/fabric-classic/spread/5": "fa7289a87111f6a2",
"B/fabric-classic/spread/6": "e1367526818066fb",
"B/fabric-classic/spread/7": "a5c72a73d9305e41",
"B/fabric-classic/spread/3": "110c2e11d8079468",
"B/fabric-classic/spread/4": "6f02c19ed62524d1",
"B/fabric-classic/spread/5": "5b0cb3424c4b894c",
"B/fabric-classic/spread/6": "28fec4ed0f8fd482",
"B/fabric-classic/spread/7": "9d68ef6e7d8b5f5a",
"B/fabric-classic/spread/8": "b11b5b3fb34a34eb",
"B/fabric-classic/spread/9": "3194c096e5c7d16f",
"B/midnight/head": "cfd309bdf9e41811",
"B/midnight/plan": "b3e4cb03c2d620a4",
"B/midnight/spread/0": "ee90988cd45ab2c4",
"B/midnight/spread/0": "7fdd20dcda48881f",
"B/midnight/spread/1": "d9f5d78659332b45",
"B/midnight/spread/10": "db97ebe8475e0371",
"B/midnight/spread/2": "a094503b82019808",
"B/midnight/spread/3": "b9fb3f915cffb57d",
"B/midnight/spread/4": "9f0467b6921de638",
"B/midnight/spread/5": "7e3d7de6ef19180c",
"B/midnight/spread/6": "d42de16c6d174c6b",
"B/midnight/spread/7": "dba5f8de6c557d9e",
"B/midnight/spread/8": "835870aa7e88f095",
"B/midnight/spread/3": "6e3e57121d8b8683",
"B/midnight/spread/4": "468100c3bebad30d",
"B/midnight/spread/5": "ca669ba76ca4d9bd",
"B/midnight/spread/6": "60b4cb974e35c55d",
"B/midnight/spread/7": "46fe0841298e4f7b",
"B/midnight/spread/8": "25e4510c5fb81817",
"B/midnight/spread/9": "eba329fd185958b3",
"B/riso/head": "69a6c3112de2a047",
"B/riso/plan": "d8906a53b3532db8",
"B/riso/spread/0": "867edf084f05a873",
"B/riso/spread/0": "2bbd6e6b4e6d1b4d",
"B/riso/spread/1": "8de92788de29277b",
"B/riso/spread/2": "40f8d2b093d0fc8d",
"B/riso/spread/3": "1fc8aedc7486cbeb",
"B/riso/spread/4": "cedfcf22d3331d4a",
"B/riso/spread/4": "311c9fcde888d578",
"B/riso/spread/5": "51562d8f08f46011",
"B/riso/spread/6": "68783b660e0ffa6a",
"B/riso/spread/6": "c114a720b5c5beff",
"B/riso/spread/7": "6f4c43c7b5f1dbc3",
"B/riso/spread/8": "a748ada7a028818e",
"B/riso/spread/9": "877657cfa95e3cac",
"C/fabric-classic/head": "ddd21691c506a600",
"C/fabric-classic/plan": "c31dcaa9b3603acc",
"C/fabric-classic/spread/0": "2ab15684c1a8df5a",
"C/fabric-classic/spread/0": "92d21ac9d8e866df",
"C/fabric-classic/spread/1": "7094bacb28cc210a",
"C/fabric-classic/spread/10": "b11b5b3fb34a34eb",
"C/fabric-classic/spread/11": "f7d9814c6b8649fd",
"C/fabric-classic/spread/2": "d0f1544330a8501f",
"C/fabric-classic/spread/3": "3600be0a98413583",
"C/fabric-classic/spread/4": "d551cdf96c385341",
"C/fabric-classic/spread/5": "177741ca8b5c1758",
"C/fabric-classic/spread/6": "2c42a8276092879e",
"C/fabric-classic/spread/7": "fd5861b762afd36b",
"C/fabric-classic/spread/2": "3e514b7dd87c7d49",
"C/fabric-classic/spread/3": "a964aa1e7f057294",
"C/fabric-classic/spread/4": "4a275bcefa9a8eb0",
"C/fabric-classic/spread/5": "10ed185ab3fd9756",
"C/fabric-classic/spread/6": "7bfbef523f58c3fc",
"C/fabric-classic/spread/7": "71330d722ab54efd",
"C/fabric-classic/spread/8": "a7f0ed591793b5e1",
"C/fabric-classic/spread/9": "2a5631a4b55fd15a",
"C/fabric-classic/spread/9": "31665bce59abeafe",
"C/midnight/head": "f1e6077175cb2e20",
"C/midnight/plan": "14fb454d02caf6f9",
"C/midnight/spread/0": "10706dcfb990de4c",
"C/midnight/spread/0": "79eae9202a85d430",
"C/midnight/spread/1": "d9f5d78659332b45",
"C/midnight/spread/10": "29566f40839aa633",
"C/midnight/spread/11": "063bb3a68427c348",
"C/midnight/spread/12": "68223f7894a7f842",
"C/midnight/spread/10": "6694684925255e94",
"C/midnight/spread/11": "247394453c45c63c",
"C/midnight/spread/12": "abe0197797669d57",
"C/midnight/spread/13": "eba329fd185958b3",
"C/midnight/spread/14": "ebbba57a4116faa3",
"C/midnight/spread/2": "894a97de13c021ed",
"C/midnight/spread/3": "97fb835ff2105cdc",
"C/midnight/spread/4": "87961fc9d8aa3915",
"C/midnight/spread/5": "4c4a134559a3516a",
"C/midnight/spread/6": "3358ec65f7d7240a",
"C/midnight/spread/7": "b7abfc765f918ddb",
"C/midnight/spread/8": "52a02b23cbe1e01a",
"C/midnight/spread/9": "9ccf347142b626ca",
"C/midnight/spread/2": "7b99390522f39fe5",
"C/midnight/spread/3": "7391268691e50ede",
"C/midnight/spread/4": "4f08a3fc6da821a5",
"C/midnight/spread/5": "6d06a2d469c2d5e2",
"C/midnight/spread/6": "6238c8bc28c5964e",
"C/midnight/spread/7": "8a5cc2233372bb47",
"C/midnight/spread/8": "a445fe1b9090293d",
"C/midnight/spread/9": "e2af9bfdb8789b4b",
"C/riso/head": "d19997b07778d1dc",
"C/riso/plan": "175dadeb8bb730a6",
"C/riso/spread/0": "11bb39722782ce9d",
"C/riso/spread/0": "8453dd7b94afe68d",
"C/riso/spread/1": "8de92788de29277b",
"C/riso/spread/10": "df3b209ca6a45afb",
"C/riso/spread/11": "48d8e38b39721c2b",
"C/riso/spread/12": "2326a58882d7c326",
"C/riso/spread/10": "6b5aa1dac73ab777",
"C/riso/spread/11": "c20262603c86d43e",
"C/riso/spread/12": "8e263c445c0418d2",
"C/riso/spread/13": "a748ada7a028818e",
"C/riso/spread/14": "81e4b4a0dcc6961a",
"C/riso/spread/2": "b25ef24c052d72a3",
"C/riso/spread/3": "1edc86f0be5c9422",
"C/riso/spread/2": "5e43386b045d926c",
"C/riso/spread/3": "9587f2fe2fc9cd04",
"C/riso/spread/4": "edb0ebe3a843a937",
"C/riso/spread/5": "1c86ab49df7921b5",
"C/riso/spread/6": "66fe84b4051bbb45",
"C/riso/spread/7": "05ed926557fdc48d",
"C/riso/spread/8": "1599830b16195a51",
"C/riso/spread/9": "dafb4c953a1c6693",
"C/riso/spread/5": "f8a935188537c224",
"C/riso/spread/6": "d2ca03597f3732c6",
"C/riso/spread/7": "7a4374ec7906855f",
"C/riso/spread/8": "2dd867bf81ca7901",
"C/riso/spread/9": "aea9baf94aa13bc8",
"D/fabric-classic/head": "54fa8987fea87ce4",
"D/fabric-classic/plan": "a2d0423833838dcc",
"D/fabric-classic/spread/0": "58ae71edfa473b8c",
"D/fabric-classic/spread/0": "1136daea45c98c7d",
"D/fabric-classic/spread/1": "931682bc5827ad8a",
"D/fabric-classic/spread/10": "f53e366c56f5026d",
"D/fabric-classic/spread/11": "36816b8e9c3e9745",
"D/fabric-classic/spread/12": "1f512d9c851fd489",
"D/fabric-classic/spread/13": "8e885c24ae9c4796",
"D/fabric-classic/spread/14": "8ed5b5e96e4f5696",
"D/fabric-classic/spread/10": "98449436f064fc7c",
"D/fabric-classic/spread/11": "e58c5ea28b6e5d14",
"D/fabric-classic/spread/12": "a422c04ae2d9e32f",
"D/fabric-classic/spread/13": "b512aff5192eae71",
"D/fabric-classic/spread/14": "b92cc5c3c532f2cb",
"D/fabric-classic/spread/15": "b11b5b3fb34a34eb",
"D/fabric-classic/spread/16": "c40cdcb18e2ef0bc",
"D/fabric-classic/spread/2": "86dd9a101af346f4",
"D/fabric-classic/spread/3": "afe19cb105dfefa0",
"D/fabric-classic/spread/4": "9fcb7ff2c09e7e05",
"D/fabric-classic/spread/5": "512f64afa749875d",
"D/fabric-classic/spread/6": "d909172ca9306881",
"D/fabric-classic/spread/7": "b267abc83d4faf93",
"D/fabric-classic/spread/8": "29c43d7551fdcedb",
"D/fabric-classic/spread/9": "932c06500ebaf54c",
"D/fabric-classic/spread/4": "80d31e5986b670e2",
"D/fabric-classic/spread/5": "664cd81373c14243",
"D/fabric-classic/spread/6": "c1d232a5aed51318",
"D/fabric-classic/spread/7": "8d08e54aa79c8c74",
"D/fabric-classic/spread/8": "18f531e0a85ff3de",
"D/fabric-classic/spread/9": "a7ddf2eb33bb75f6",
"D/midnight/head": "da510cf5f95bad40",
"D/midnight/plan": "3bd0d52ed4e476c7",
"D/midnight/spread/0": "cc12862834fdbf96",
"D/midnight/spread/0": "440b7d69daa18a7a",
"D/midnight/spread/1": "d9f5d78659332b45",
"D/midnight/spread/10": "d25808fa155f157e",
"D/midnight/spread/11": "b984e665960f60b6",
"D/midnight/spread/12": "a0985fcab9c614a2",
"D/midnight/spread/13": "3f2670ca15c1797c",
"D/midnight/spread/14": "b49cc00919f7a621",
"D/midnight/spread/15": "67a512730030f450",
"D/midnight/spread/16": "90864ccfce090575",
"D/midnight/spread/17": "69924ede00c784f9",
"D/midnight/spread/10": "824d62135a0fe827",
"D/midnight/spread/11": "03bbcf527fcbce9f",
"D/midnight/spread/12": "d2e1ae86b69d8a5c",
"D/midnight/spread/13": "7025aef32f444ab2",
"D/midnight/spread/14": "581f0008497c1143",
"D/midnight/spread/15": "f86784f4b061a31e",
"D/midnight/spread/16": "ec6c98d1713ff353",
"D/midnight/spread/17": "03888512d15a5895",
"D/midnight/spread/18": "eba329fd185958b3",
"D/midnight/spread/19": "6e6e6c877443c5e7",
"D/midnight/spread/2": "cef4235690d44e53",
"D/midnight/spread/3": "cd34cb85119476ee",
"D/midnight/spread/4": "f332e826ccb518b8",
"D/midnight/spread/5": "545524d900a6d68e",
"D/midnight/spread/6": "ea5db0eab1087429",
"D/midnight/spread/7": "da860dcfdcffc997",
"D/midnight/spread/8": "d4a871126c6b324e",
"D/midnight/spread/9": "533ceb3478922532",
"D/midnight/spread/4": "f9782906a14ae092",
"D/midnight/spread/5": "597d729fb1cfb962",
"D/midnight/spread/6": "0023fe5672ad9141",
"D/midnight/spread/7": "f79ae699d0684ce5",
"D/midnight/spread/8": "ea1a0157b2405cff",
"D/midnight/spread/9": "5d38d38d1226fe1e",
"D/riso/head": "1c8f99d99acdea28",
"D/riso/plan": "f2a8704019582093",
"D/riso/spread/0": "7cded5c557f2e1ee",
"D/riso/spread/0": "7b0d816ffda1dd01",
"D/riso/spread/1": "8de92788de29277b",
"D/riso/spread/10": "1d403f4e5dc74e1e",
"D/riso/spread/11": "7b563aff540a0a2c",
"D/riso/spread/12": "c6cf876fbc756d21",
"D/riso/spread/13": "735d4882d5230331",
"D/riso/spread/14": "8df18b93ab68235d",
"D/riso/spread/15": "4be16c00138d027d",
"D/riso/spread/10": "c85c0634205e3232",
"D/riso/spread/11": "f97635aee763f93e",
"D/riso/spread/12": "37f5d0dc381cfdb4",
"D/riso/spread/13": "7083d486cc5556c5",
"D/riso/spread/14": "711a3787fce72914",
"D/riso/spread/15": "27d0db240d32de9a",
"D/riso/spread/16": "a748ada7a028818e",
"D/riso/spread/17": "ed87c0a4c77f241c",
"D/riso/spread/2": "78f24755fb7ad38a",
"D/riso/spread/3": "047741da3e1adc20",
"D/riso/spread/4": "42d13c4cc22154a9",
"D/riso/spread/5": "c2dcc1ba31041587",
"D/riso/spread/6": "93846db9c48faa0f",
"D/riso/spread/7": "42f55e08792400e9",
"D/riso/spread/4": "294d0c2a0600ed4a",
"D/riso/spread/5": "b1f6c2832a5f5737",
"D/riso/spread/6": "a898defea41081ec",
"D/riso/spread/7": "027026c49ea68a27",
"D/riso/spread/8": "4ff93cb4d24f729c",
"D/riso/spread/9": "988a20c009c9d562"
}
@@ -1,6 +1,12 @@
import type { AutoEntry, AutoInput, AutoPhoto } from '../auto/auto-types';
import { normalizeBookDocument } from '../book-normalize';
import { MAX_SPREAD_ELEMENTS, bookDocumentSchema, type BookDocument, type BookSpread } from '../book.schema';
import {
MAX_SPREADS,
MAX_SPREAD_ELEMENTS,
bookDocumentSchema,
type BookDocument,
type BookSpread,
} from '../book.schema';
import { pageFindings } from '../describe-spread';
import { findDecoratedTemplate } from '../templates/template-catalogue';
import { templatePhotoSlots } from '../templates/template-format';
@@ -95,12 +101,13 @@ function single(photos: number, story: boolean): BookDesignInput {
}
describe('the book on the designed templates', () => {
it('AUTODSN-001: is a valid book, as normalised, within the element cap', () => {
it('AUTODSN-001: is a valid book, as normalised, within the spread and element caps', () => {
for (const [name, input] of inputs()) {
for (const theme of THEMES) {
const { doc } = laid(input, theme);
expect(bookDocumentSchema.safeParse(doc).success, `${name} ${theme}`).toBe(true);
expect(normalizeBookDocument(doc), `${name} ${theme}`).toEqual(doc);
expect(doc.spreads.length).toBeLessThanOrEqual(MAX_SPREADS);
for (const s of doc.spreads) expect(s.elements.length).toBeLessThanOrEqual(MAX_SPREAD_ELEMENTS);
}
}
+3 -1
View File
@@ -9,6 +9,7 @@ import type { BookTemplateWord } from '../templates/template-words';
import type { BookDesignInput } from './design-book';
import { cutWords, liveBox, photoEl, textEl, type ComposeCtx } from './design-compose';
import { composeStations } from './design-framing';
import type { FramingNeeds } from './design-inner';
import type { DesignedSpread } from './design-merge';
import type { BookDesignBeat } from './design-pacing';
import { buildChapters, type DesignChapter } from './design-pacing';
@@ -437,11 +438,12 @@ export function composeTemplatedInner(
entries: readonly AutoEntry[],
choice: DesignPhotoChoice,
input: BookDesignInput,
needs: FramingNeeds,
plan: TemplatedPlan,
): TemplatedInnerResult {
const notes: string[] = [];
const leftOut: BookDesignLeftOut[] = [];
const cap = Math.max(0, c.directives.pages?.max ?? Number.POSITIVE_INFINITY);
const cap = Math.max(0, Math.min(c.directives.pages?.max ?? needs.room, needs.room));
const head = framingPages(c, plan.archetype).slice(0, Math.max(0, cap - 1));
const base: PickBase = {
archetype: plan.archetype,
-2
View File
@@ -191,8 +191,6 @@ const assistant: TranslationStrings = {
'assistant.change.label.currency': 'العملة',
'assistant.change.label.stopType': 'نوع التوقف',
'assistant.change.label.text': 'النص',
'assistant.change.label.title': 'العنوان',
'assistant.change.label.content': 'المحتوى',
'assistant.change.label.icon': 'الأيقونة',
'assistant.change.label.color': 'اللون',
'assistant.change.label.position': 'الترتيب',
-6
View File
@@ -177,12 +177,6 @@ const docsync: TranslationStrings = {
'docsync.error.provider_disabled':
'متوقفة مؤقتاً: أوقف مسؤول الخادم هذا المزوّد. ستُستأنف المزامنة عند إعادة تشغيله.',
'docsync.binding.reconnect': 'إعادة الاتصال',
'docsync.connection.title': 'الاتصال',
'docsync.connection.hint': 'العنوان وبيانات الدخول التي يستخدمها TREK لهذا المخزن.',
'docsync.connection.edit': 'تعديل الاتصال',
'docsync.connection.remove': 'إزالة الاتصال',
'docsync.connection.confirmRemove':
'ينسى TREK عنوان هذا المخزن وبيانات الدخول إليه، وتتوقف كل مزامنة لهذه الرحلة عبره. تبقى المستندات في TREK وفي المخزن.',
};
export default docsync;
-2
View File
@@ -157,8 +157,6 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'حول المدة الإجمالية المخططة',
'tours.planner.plannedTotalManual': 'تعديل يدوي',
'tours.planner.timeValueInvalid': 'أدخل قيمة صحيحة من 0 إلى 1440 دقيقة.',
'tours.planner.plannedTotalOverDay':
'وقت المشي مع الاستراحات يزيد على 24 ساعة في اليوم. اجعلها جولة لعدة أيام عبر «إنهاء المرحلة هنا» عند إحدى المحطات، أو أدخل مدة إجمالية مخططة أقصر.',
'tours.planner.walkingTimeUnknown': 'غير محسوب',
'tours.planner.walkingShort': 'مشي',
'tours.planner.breaksShort': 'استراحات',
-2
View File
@@ -182,8 +182,6 @@ const assistant: TranslationStrings = {
'assistant.change.label.currency': 'Valyuta',
'assistant.change.label.stopType': 'Dayanacaq növü',
'assistant.change.label.text': 'Mətn',
'assistant.change.label.title': 'Başlıq',
'assistant.change.label.content': 'Məzmun',
'assistant.change.label.icon': 'İkon',
'assistant.change.label.color': 'Rəng',
'assistant.change.label.position': 'Sıra',

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