Compare commits

..

1 Commits

Author SHA1 Message Date
Copilot 134b420cd1 Add Turkish (tr) to client i18n and language registry
* feat(i18n): add Turkish language support and base tr translations

Agent-Logs-Url: https://github.com/SkyLostTR/TREK/sessions/f86511d9-8ff1-4459-8ec1-879936135741

Co-authored-by: SkyLostTR <21984261+SkyLostTR@users.noreply.github.com>

* test(i18n): update language list expectations for Turkish support
2026-05-19 16:00:53 +03:00
3525 changed files with 104711 additions and 490594 deletions
+2 -2
View File
@@ -2,7 +2,6 @@ node_modules
client/node_modules
server/node_modules
client/dist
shared/dist
data
uploads
.git
@@ -30,7 +29,8 @@ Thumbs.db
sonar-project.properties
server/tests/
server/vitest.config.ts
server/reset-admin.js
**/*.test.ts
**/*.spec.ts
wiki/
scripts/
charts/
+2 -2
View File
@@ -8,11 +8,11 @@ body:
attributes:
label: Pre-flight checklist
options:
- label: I have searched [existing issues](https://github.com/liketrek/TREK/issues) and this bug has not been reported yet
- label: I have searched [existing issues](https://github.com/mauriceboe/TREK/issues) and this bug has not been reported yet
required: true
- label: I am running the latest available version of TREK
required: true
- label: I have read the [Troubleshooting guide](https://github.com/liketrek/TREK/wiki/Troubleshooting) and my issue is not covered there
- label: I have read the [Troubleshooting guide](https://github.com/mauriceboe/TREK/wiki/Troubleshooting) and my issue is not covered there
required: true
- type: input
+3 -3
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Documentation
url: https://github.com/liketrek/TREK/wiki
url: https://github.com/mauriceboe/TREK/wiki
about: Check the docs before opening an issue
- name: Feature Request
url: https://github.com/liketrek/TREK/discussions/new?category=feature-requests
url: https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests
about: Suggest a new feature or improvement in Discussions
- name: Questions & Help
url: https://github.com/liketrek/TREK/discussions
url: https://github.com/mauriceboe/TREK/discussions
about: For questions and general help, use Discussions instead
+2 -2
View File
@@ -13,8 +13,8 @@
- [ ] Documentation update
## Checklist
- [ ] I have read the [Contributing Guidelines](https://github.com/liketrek/TREK/wiki/Contributing)
- [ ] My branch is [up to date with `dev`](https://github.com/liketrek/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
- [ ] This PR targets the `dev` branch, not `main` *(wiki-only PRs are exempt)*
- [ ] I have tested my changes locally
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
@@ -9,7 +9,6 @@ permissions:
jobs:
close-stale:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
steps:
- name: Close stale invalid-title issues
@@ -10,7 +10,6 @@ permissions:
jobs:
close-stale:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
steps:
- name: Close stale wrong-base-branch PRs
+1 -2
View File
@@ -9,7 +9,6 @@ permissions:
jobs:
check-title:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
steps:
- name: Flag or redirect issue
@@ -77,7 +76,7 @@ jobs:
body: [
'## Wrong place for feature requests',
'',
'Feature requests should be submitted in [Discussions](https://github.com/liketrek/TREK/discussions/new?category=feature-requests), not as issues.',
'Feature requests should be submitted in [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests), not as issues.',
'',
'This issue has been closed. Feel free to re-submit your idea in the right place!',
].join('\n'),
-1
View File
@@ -18,7 +18,6 @@ concurrency:
jobs:
version-bump:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.VERSION }}
+16 -13
View File
@@ -1,6 +1,16 @@
name: Build & Push Docker Image
on:
push:
branches: [main]
paths-ignore:
- 'docs/**'
- '**/*.md'
- 'wiki/**'
- '.github/workflows/**'
- '.github/ISSUE_TEMPLATE/**'
- '.github/FUNDING.yml'
- '.github/PULL_REQUEST_TEMPLATE.md'
workflow_dispatch:
inputs:
bump:
@@ -22,22 +32,15 @@ concurrency:
jobs:
version-bump:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.VERSION }}
steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
token: ${{ steps.app-token.outputs.token }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Determine bump type and update version
id: bump
@@ -99,17 +102,18 @@ jobs:
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$STABLE → $NEW_VERSION ($BUMP)"
# Update all workspace + root package.json files and the root lockfile in one shot
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
# Update package.json files and Helm chart
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
# Commit and tag
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json server/package.json client/package.json shared/package.json nest-mcp/package.json charts/trek/Chart.yaml
git add server/package.json server/package-lock.json client/package.json client/package-lock.json charts/trek/Chart.yaml
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION"
git tag "v$NEW_VERSION"
git push origin main --follow-tags
build:
@@ -214,4 +218,3 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
charts_dir: charts
charts_url: https://chart.liketrek.com
@@ -6,7 +6,6 @@ on:
jobs:
check-target:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
permissions:
pull-requests: write
-53
View File
@@ -1,53 +0,0 @@
name: Lint & Prettier
on:
pull_request:
branches: [main, dev]
jobs:
lint:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
- name: Install dependencies
run: npm install
- name: Run lint & format check
id: checks
continue-on-error: true
run: |
cd shared
npm run lint
npm run format:check
- name: Comment on PR if checks failed
if: steps.checks.outcome == 'failure'
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: [
'## ❌ Lint & Prettier check failed',
'',
'Please fix the issues locally by running the following commands inside the `shared` package:',
'',
'```bash',
'cd shared',
'npm run lint',
'npm run format',
'```',
'',
'Then commit and push the changes.',
].join('\n'),
});
- name: Fail the job if checks failed
if: steps.checks.outcome == 'failure'
run: exit 1
-33
View File
@@ -1,33 +0,0 @@
name: Publish plugin-sdk to npm
# Publishes trek-plugin-sdk when a tag like `plugin-sdk-v1.2.0` is pushed.
# One-time setup: add an npm automation token as the repo secret NPM_TOKEN.
# The package's prepublishOnly hook builds + tests before publishing.
on:
push:
tags:
- 'plugin-sdk-v*'
permissions:
contents: read
jobs:
publish:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
defaults:
run:
working-directory: plugin-sdk
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
# 22 matches the TREK server runtime and has node:sqlite, which the
# dev-server tests exercise.
node-version: 22
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
-4
View File
@@ -11,9 +11,6 @@ permissions:
jobs:
scout:
# Docker Hub secrets are not exposed to pull requests from forks, so the
# Scout login can never succeed there.
if: github.repository == 'liketrek/TREK' && github.event.pull_request.head.repo.fork != true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -37,5 +34,4 @@ jobs:
command: cves
image: trek:scan
only-severities: critical,high
only-fixed: true
exit-code: true
+7 -147
View File
@@ -8,83 +8,10 @@ on:
branches: [main, dev]
paths:
- 'server/**'
- 'client/**'
- 'shared/**'
- 'nest-mcp/**'
- '.github/workflows/test.yml'
- 'client/**'
jobs:
i18n-parity:
name: i18n Key Parity
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
- name: Check i18n key parity
run: node shared/scripts/i18n-parity.mjs --strict
shared-contracts:
name: Shared Contracts (Zod)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared
- name: Typecheck
run: cd shared && npm run typecheck
- name: Run tests
run: cd shared && npm test
nest-mcp-package:
name: nest-mcp Package
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace nest-mcp
- name: Ensure @swc/core's Linux binary for unplugin-swc
# Same lockfile quirk as server-tests: the Linux native binary is
# omitted, and nest-mcp's vitest config uses the SWC transform too.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Build
run: npm run build --workspace=nest-mcp
- name: Typecheck
run: cd nest-mcp && npm run typecheck
- name: Lint
run: cd nest-mcp && npm run lint:check
- name: Run tests
run: cd nest-mcp && npm test
server-tests:
name: Server Tests
runs-on: ubuntu-latest
@@ -94,44 +21,12 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 24
node-version: 22
cache: npm
cache-dependency-path: package-lock.json
cache-dependency-path: server/package-lock.json
- name: Install dependencies
run: npm ci
- name: Ensure @swc/core's Linux binary for unplugin-swc
# The lockfile was generated on Windows and omits @swc/core's Linux
# optional native binary, so npm ci/install skips it on the runner.
# Install the matching version explicitly so the server's SWC transform
# (server/vitest.config.ts) can load.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Build shared
run: npm run build --workspace=shared
- name: Build nest-mcp
# Server typecheck/build resolve @trek/nest-mcp's types from its dist
# (tests alias the package source, but tsc does not).
run: npm run build --workspace=nest-mcp
- name: Build server (tsc -> dist)
run: cd server && npm run build
- name: Smoke production require chain
# Vitest aliases @trek/nest-mcp to its source, so only this exercises
# what production runs: nest-mcp's built dist resolving the MCP SDK's
# subpath exports through the tsconfig-paths/register runtime hook.
run: cd server && node --require tsconfig-paths/register -e "require('@trek/nest-mcp')"
- name: Typecheck
run: cd server && npm run typecheck
- name: Lint
run: cd server && npm run lint:check
run: cd server && npm ci
- name: Run tests
run: cd server && npm run test:coverage
@@ -144,38 +39,6 @@ jobs:
path: server/coverage/
retention-days: 7
client-quality:
# Split out of client-tests: the suite takes ~11 minutes, and having the
# gates in front of it meant a single lint finding threw away the whole test
# signal for that run. Both jobs pay the install/build, which is cheap next
# to running the two in series.
name: Client Types & Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared && npm ci --workspace client
- name: Build shared
run: npm run build --workspace=shared
- name: Typecheck
run: cd client && npm run typecheck
- name: Lint
run: cd client && npm run lint:check
- name: Page pattern check
run: cd client && npm run lint:pages
client-tests:
name: Client Tests
runs-on: ubuntu-latest
@@ -185,15 +48,12 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 24
node-version: 22
cache: npm
cache-dependency-path: package-lock.json
cache-dependency-path: client/package-lock.json
- name: Install dependencies
run: npm ci --workspace shared && npm ci --workspace client
- name: Build shared
run: npm run build --workspace=shared
run: cd client && npm ci
- name: Run tests
run: cd client && npm run test:coverage
-1
View File
@@ -17,7 +17,6 @@ concurrency:
jobs:
deploy:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+1 -4
View File
@@ -3,8 +3,6 @@ node_modules/
# Build output
client/dist/
server/dist/
shared/dist/
server/public/*
!server/public/.gitkeep
@@ -51,7 +49,6 @@ yarn-error.log*
# Coverage
coverage
coverage-*/
*.lcov
.nyc_output
@@ -66,4 +63,4 @@ coverage-*/
test-data
.run
.full-review
.full-review
-136
View File
@@ -1,136 +0,0 @@
# Contributor Covenant 3.0 Code of Conduct
## Our Pledge
We pledge to make our community welcoming, safe, and equitable for all.
We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all
individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics,
neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or
religion, national or social origin, socio-economic position, level of education, or other status. The same privileges
of participation are extended to everyone who participates in good faith and in accordance with this Covenant.
## Encouraged Behaviors
While acknowledging differences in social norms, we all strive to meet our community's expectations for positive
behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture,
background, or native language.
With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared
values, including:
1. Respecting the **purpose of our community**, our activities, and our ways of gathering.
2. Engaging **kindly and honestly** with others.
3. Respecting **different viewpoints** and experiences.
4. **Taking responsibility** for our actions and contributions.
5. Gracefully giving and accepting **constructive feedback**.
6. Committing to **repairing harm** when it occurs.
7. Behaving in other ways that promote and sustain the **well-being of our community**.
## Restricted Behaviors
We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are
violations of this Code of Conduct.
1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any
clear request to stop.
2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of
people.
3. **Stereotyping or discrimination.** Characterizing anyones personality or behavior on the basis of immutable
identities or traits.
4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or
purpose of the community.
5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their
permission.
6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group.
7. Behaving in other ways that **threaten the well-being** of our community.
### Other Restrictions
1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade
enforcement actions.
2. **Failing to credit sources.** Not properly crediting the sources of content you contribute.
3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the
community.
4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other
restricted behaviors.
## Reporting an Issue
Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict
represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help
avoid conflicts and minimize harm.
When an incident does occur, it is important to report it promptly. To report a possible violation, **send an email to
report@liketrek.com**.
Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They
will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing
witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as
possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried
out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon
resolution.
## Addressing and Repairing Harm
****
If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following
enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals
involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be
skipped.
1) Warning
1) Event: A violation involving a single incident or series of incidents.
2) Consequence: A private, written warning from the Community Moderators.
3) Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking
clarification on expectations.
2) Temporarily Limited Activities
1) Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a
more serious violation.
2) Consequence: A private, written warning with a time-limited cooldown period designed to underscore the
seriousness of the situation and give the community members involved time to process the incident. The cooldown
period may be limited to particular communication channels or interactions with particular community members.
3) Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and
impact, and being thoughtful about re-entering community spaces after the period is over.
3) Temporary Suspension
1) Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a
single serious violation.
2) Consequence: A private written warning with conditions for return from suspension. In general, temporary
suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions.
3) Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for
return, and being thoughtful about how to reintegrate with the community when the suspension is lifted.
4) Permanent Ban
1) Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or
a violation so serious that the Community Moderators determine there is no way to keep the community safe with
this person as a member.
2) Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent
bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working
through other remedies has failed to change the behavior.
3) Repair: There is no possible repair in cases of this severity.
This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their
discretion and judgment, in keeping with the best interests of our community.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing
the community in public or other spaces. Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available
at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/).
Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy
of this license,
visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/)
For answers to common questions about Contributor Covenant, see the FAQ
at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided
at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional
enforcement and community guideline resources can be found
at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement
ladder was inspired by the work of [Mozillas code of conduct team](https://github.com/mozilla/inclusion).
+3 -3
View File
@@ -10,7 +10,7 @@ Thanks for your interest in contributing! Please read these guidelines before op
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`. Exception: PRs that only modify files under `wiki/` may target any branch
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/liketrek/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
## Pull Requests
@@ -39,8 +39,8 @@ feat(budget): add CSV export for expenses
## Development Environment
See the [Developer Environment page](https://github.com/liketrek/TREK/wiki/Development-environment) for more information on setting up your development environment.
See the [Developer Environment page](https://github.com/mauriceboe/TREK/wiki/Development-environment) for more information on setting up your development environment.
## More Details
See the [Contributing wiki page](https://github.com/liketrek/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
See the [Contributing wiki page](https://github.com/mauriceboe/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
+22 -98
View File
@@ -1,103 +1,31 @@
# ── Stage 0: gosu ────────────────────────────────────────────────────────────
# Rebuild gosu with a current Go toolchain so the runtime image ships no stale
# Go stdlib (Debian's apt gosu is built with an old Go that trips CVE scanners).
# The binary and its runtime behaviour are identical to the apt package.
FROM golang:1.25-alpine AS gosu-build
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@latest
# ── Stage 1: shared ──────────────────────────────────────────────────────────
FROM node:24-alpine AS shared-builder
WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
RUN npm ci --workspace=shared
COPY shared/ ./shared/
RUN npm run build --workspace=shared
# ── Stage 2: client ──────────────────────────────────────────────────────────
# Stage 1: Build React client
FROM node:24-alpine AS client-builder
WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY client/package.json ./client/
RUN npm ci --workspace=client
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY client/ ./client/
RUN npm run build --workspace=client
WORKDIR /app/client
COPY client/package*.json ./
RUN npm ci
COPY client/ ./
RUN npm run build
# ── Stage 3: server ──────────────────────────────────────────────────────────
# --ignore-scripts skips native builds (better-sqlite3); they happen in the production stage.
FROM node:24-alpine AS server-builder
WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY nest-mcp/package.json ./nest-mcp/
COPY server/package.json ./server/
RUN npm ci --workspace=server --ignore-scripts
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY nest-mcp/ ./nest-mcp/
RUN npm run build --workspace=nest-mcp
COPY server/ ./server/
RUN npm run build --workspace=server
# Stage 2: Production server
FROM node:24-alpine
# ── Stage 4: production runtime ──────────────────────────────────────────────
FROM node:24-trixie-slim
WORKDIR /app
# Workspace manifests only — source never enters this stage.
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY nest-mcp/package.json ./nest-mcp/
COPY server/package.json ./server/
# Timezone support + native deps (better-sqlite3 needs build tools)
COPY server/package*.json ./
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
npm ci --production && \
rm package-lock.json && \
apk del python3 make g++ && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
RUN apt-get update && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \
libkitinerary-bin && \
npm ci --workspace=server --omit=dev && \
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
apt-get purge -y python3 build-essential && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
COPY server/ ./
COPY --from=client-builder /app/client/dist ./public
COPY --from=client-builder /app/client/public/fonts ./public/fonts
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
ENV XDG_CACHE_HOME=/tmp/kf6-cache
# Prevent Qt from probing for a display in headless containers.
ENV QT_QPA_PLATFORM=offscreen
# Fixed path for both amd64 (static binary) and arm64 (symlink to apt binary).
# Override with KITINERARY_EXTRACTOR_PATH if you install it elsewhere.
ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
COPY --from=server-builder /app/server/dist ./server/dist
# Runtime data assets read from server/assets at runtime: airports.json (flight
# transport search) and atlas/*.geojson.gz (Atlas country/region map). The build
# only emits dist, so these must be copied explicitly or the features silently
# degrade to empty in the image.
COPY --from=server-builder /app/server/assets ./server/assets
# The in-app help pages (/help) read this straight from disk at runtime, so the
# docs always match the version running. Without it, wikiService falls back to
# fetching the GitHub wiki, which tracks main and needs network access.
COPY wiki ./wiki
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
COPY server/tsconfig.json ./server/
# Encryption-key rotation is run on demand via tsx (a prod dep) straight from the
# raw .ts source — it never enters dist, so it must be copied in explicitly or
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
# Admin recovery script (node server/reset-admin.js) for locked-out installs.
COPY server/reset-admin.js ./server/reset-admin.js
COPY --from=shared-builder /app/shared/dist ./shared/dist
# server dist requires @trek/nest-mcp at runtime through the workspace symlink;
# its dist's MCP SDK subpath requires ride the same tsconfig-paths/register
# hook the server already boots with.
COPY --from=server-builder /app/nest-mcp/dist ./nest-mcp/dist
COPY --from=client-builder /app/client/dist ./server/public
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
ln -s /app/uploads /app/server/uploads && \
ln -s /app/data /app/server/data && \
RUN rm -f package-lock.json && \
mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
chown -R node:node /app
ENV NODE_ENV=production
@@ -111,8 +39,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
ENTRYPOINT ["dumb-init", "--"]
# Preflight: if the app code is missing, a volume was almost certainly mounted
# over /app (it hides the image's node_modules + dist). Fail with actionable
# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
CMD ["sh", "-c", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node --import tsx src/index.ts"]
-33
View File
@@ -1,33 +0,0 @@
# Third-party data & attributions
TREK bundles and uses third-party data that requires attribution.
## geoBoundaries — country & sub-national boundaries
The Atlas map's administrative boundaries (admin-0 countries and admin-1
provinces/counties), shipped at `server/assets/atlas/admin0.geojson.gz` and
`server/assets/atlas/admin1.geojson.gz` and generated by
`server/scripts/build-atlas-geo.mjs`, are derived from **geoBoundaries**.
> Runfola, D. et al. (2020) geoBoundaries: A global database of political
> administrative boundaries. PLoS ONE 15(4): e0231866.
> https://doi.org/10.1371/journal.pone.0231866
geoBoundaries is licensed under **CC BY 4.0**
(https://creativecommons.org/licenses/by/4.0/). Source: https://www.geoboundaries.org/
The bundled files are simplified (coordinate-quantized) and re-tagged with the
property names TREK consumes. Country borders (`admin0`) derive from the geoBoundaries
CGAZ composite; sub-national regions (`admin1`) derive from the per-country open
(gbOpen) release.
## OpenStreetMap — geocoding
Atlas reverse-geocodes places via the **Nominatim** service. Geocoding data is
© OpenStreetMap contributors, licensed under the Open Database License (ODbL).
https://www.openstreetmap.org/copyright
## OurAirports — airport reference data
`server/assets/airports.json` is built from **OurAirports**
(https://ourairports.com/data/), released into the public domain.
+23 -61
View File
@@ -18,7 +18,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<br />
<a href="https://demo.liketrek.com"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
<a href="https://demo-nomad.pakulat.org"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
&nbsp;
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
&nbsp;
@@ -31,9 +31,9 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
<br />
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/trek?include_prereleases&style=flat-square&color=6B7280" /></a>
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=flat-square&color=6B7280" /></a>
<a href="https://github.com/mauriceboe/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
</div>
@@ -41,7 +41,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<div align="center">
<img src="https://github.com/liketrek/TREK-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
</div>
@@ -49,12 +49,12 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<div align="center">
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner · day plan & route" width="49%" /></a>
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a>
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Budget tracker" width="49%" /></a>
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
<a href="docs/screenshots/collections.png"><img src="docs/screenshots/collections.png" alt="Collections · saved place lists" width="49%" /></a>
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Iceland Ring Road" width="49%" /></a>
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
</div>
@@ -79,7 +79,6 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
- **Route optimisation** — auto-sort places and export to Google Maps
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
@@ -90,8 +89,8 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### 🧳 Travel management
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
- **Packing lists** — categories, templates, user assignment, progress tracking
- **Bag tracking** — optional weight tracking with iOS-style distribution
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
@@ -109,7 +108,6 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
- **Invite links** — one-time or reusable links with expiry
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
- **2FA** — TOTP + backup codes
- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable
- **Collab suite** — group chat, shared notes, polls, day check-ins
</td>
@@ -130,13 +128,13 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### 🧩 Addons (admin-toggleable)
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency
- **Budget** — expense tracker with splits, pie chart, multi-currency
- **Documents** — file attachments on trips, places, and reservations
- **Collab** — chat, notes, polls, day-by-day attendance
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, approved school holiday overlays, carry-over tracking
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
- **Naver List Import** — one-click import from shared Naver Maps lists
- **MCP** — expose TREK to AI assistants via OAuth 2.1
</td>
@@ -158,9 +156,8 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### ⚙️ Admin & customisation
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
</td>
@@ -194,9 +191,9 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
<div align="center">
![Node.js](https://img.shields.io/badge/Node.js_22-339933?style=flat-square&logo=node.js&logoColor=white)
![NestJS](https://img.shields.io/badge/NestJS_11-E0234E?style=flat-square&logo=nestjs&logoColor=white)
![Express](https://img.shields.io/badge/Express-000000?style=flat-square&logo=express&logoColor=white)
![SQLite](https://img.shields.io/badge/SQLite-003B57?style=flat-square&logo=sqlite&logoColor=white)
![React](https://img.shields.io/badge/React_19-61DAFB?style=flat-square&logo=react&logoColor=black)
![React](https://img.shields.io/badge/React_18-61DAFB?style=flat-square&logo=react&logoColor=black)
![Vite](https://img.shields.io/badge/Vite-646CFF?style=flat-square&logo=vite&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white)
![Tailwind](https://img.shields.io/badge/Tailwind-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white)
@@ -205,7 +202,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
</div>
Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + Passkeys (WebAuthn) + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
<br />
@@ -266,7 +263,7 @@ Then:
docker compose up -d
```
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells the server how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
</details>
@@ -275,12 +272,12 @@ docker compose up -d
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
```bash
helm repo add trek https://chart.liketrek.com
helm repo add trek https://mauriceboe.github.io/TREK
helm repo update
helm install trek trek/trek
```
See [`charts/README.md`](https://github.com/liketrek/TREK/blob/main/charts/README.md) for values.
See [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts/README.md) for values.
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
@@ -314,9 +311,6 @@ docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/upl
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
> [!IMPORTANT]
> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved.
<h3>Rotating the Encryption Key</h3>
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
@@ -331,8 +325,6 @@ The script creates a timestamped DB backup before making changes and prompts for
For production, put TREK behind a TLS-terminating reverse proxy. TREK uses WebSockets for real-time sync, so the proxy **must** support WebSocket upgrades on `/ws`.
If you use the MCP addon, the proxy must also pass the `Mcp-Session-Id` header through in both directions on `/mcp` — Nginx and Caddy do this by default, but a proxy that strips it makes every tool call open a new session instead of reusing one. See the [Reverse Proxy wiki page](https://github.com/liketrek/TREK/wiki/Reverse-Proxy) for details.
<details>
<summary>Nginx</summary>
@@ -370,19 +362,6 @@ server {
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
# Only needed if you use the MCP addon. Responses are Server-Sent Events,
# so buffering must be off or tool results arrive late.
location /mcp {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 3600s;
}
}
```
@@ -405,13 +384,6 @@ Caddy handles TLS and WebSockets automatically.
## Environment variables
> [!NOTE]
> Variables are validated at startup (fail-fast). An unset or blank variable
> always falls back to its default, but a variable set to a malformed value
> (e.g. `PORT=abc`, `SESSION_DURATION=bogus`, `DEMO_MODE=maybe`) aborts boot
> with a report listing every offending variable. Boolean switches accept
> `true`/`false`, `1`/`0`, `on`/`off` and `yes`/`no` (any casing).
<details>
<summary><b>Full reference</b></summary>
@@ -425,15 +397,12 @@ Caddy handles TLS and WebSockets automatically.
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
| `TREK_WIKI_DIR` | Where the in-app Help pages (`/help`) read their content from. TREK ships its wiki and serves it from disk, so Help always matches the version you are running — you should not need to set this. Point it at your own directory to serve custom docs. If the path does not exist, Help falls back to fetching the public GitHub wiki (needs outbound network, and tracks the latest release). | bundled `wiki/` |
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `en` |
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
| `SESSION_DURATION` | How long a login session stays valid when **"Remember me" is unchecked** (the default): sets the `trek_session` JWT `exp` and issues a browser-session cookie (cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. | `24h` |
| `SESSION_DURATION_REMEMBER` | Session length when **"Remember me" is ticked** at login: a longer-lived JWT plus a persistent `trek_session` cookie that survives browser restarts. Same format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
| **OIDC / SSO** | | |
@@ -451,9 +420,8 @@ Caddy handles TLS and WebSockets automatically.
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
| **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user. At the cap, the least-recently-active session is closed to make room | `20` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
</details>
@@ -469,13 +437,7 @@ Caddy handles TLS and WebSockets automatically.
<br />
## Data sources
The Atlas map's country and sub-national (province/county) boundaries come from
[**geoBoundaries**](https://www.geoboundaries.org/) (Runfola et al., 2020), licensed
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md)
for full third-party attributions.
## License
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
+1 -1
View File
@@ -14,7 +14,7 @@ Only the latest version receives security updates. Please update to the latest r
If you discover a security vulnerability, please report it responsibly:
1. **Do not** open a public issue
2. Email: **report@liketrek.com**
2. Emails: **mauriceboe@icloud.com**, **trek-security@jubnl.ch**
3. Include a description of the vulnerability and steps to reproduce
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
CLIENT_DIR="$REPO_ROOT/client"
SERVER_DIR="$REPO_ROOT/server"
PUBLIC_DIR="$REPO_ROOT/server/public"
echo "==> Installing client dependencies"
cd "$CLIENT_DIR"
npm ci
echo "==> Building client"
npm run build
echo "==> Installing server dependencies"
cd "$SERVER_DIR"
npm ci
echo "==> Populating server/public"
find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete
cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/"
cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts"
echo "==> Done — server/public is ready"
-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0"?>
<CommunityApplications>
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
<Icon>https://raw.githubusercontent.com/liketrek/TREK/main/docs/trek-icon.png</Icon>
<WebPage>https://github.com/liketrek/TREK</WebPage>
<Forum>https://github.com/liketrek/TREK/issues</Forum>
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
<DonateText>Support TREK development</DonateText>
</CommunityApplications>
+2 -6
View File
@@ -15,13 +15,11 @@ This is a minimal Helm chart for deploying the TREK app.
A hosted Helm repository is available:
```sh
helm repo add trek https://chart.liketrek.com
helm repo add trek https://mauriceboe.github.io/TREK
helm repo update
helm install trek trek/trek
```
> **Note:** `chart.liketrek.com` is a custom domain (CNAME) for the GitHub Pages site at `https://liketrek.github.io/TREK` — both URLs serve the same repository. The github.io URL keeps working (it redirects to `chart.liketrek.com`), but the custom domain is the canonical one to use.
## Usage
Or install directly from the local chart:
@@ -41,9 +39,7 @@ See `values.yaml` for more options.
## Notes
- Ingress is off by default. Enable and configure hosts for your domain.
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
- To use your own PVCs, set `persistence.data.existingClaim` and/or `persistence.uploads.existingClaim`. The other values for that volume (size, storageClassName, annotations) are then ignored.
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
- PVCs require a default StorageClass or specify one as needed.
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.4.1
version: 3.0.18
description: Minimal Helm chart for TREK app
appVersion: "3.4.1"
appVersion: "3.0.18"
-6
View File
@@ -21,9 +21,3 @@
4. Only one method should be used at a time. If both `generateEncryptionKey` and `existingSecret` are
set, `existingSecret` takes precedence. Ensure the referenced secret and key exist in the namespace.
5. Persistence:
- To bind your own PVCs, set `persistence.data.existingClaim` and/or `persistence.uploads.existingClaim`.
The other values for that volume (size, storageClassName, annotations) are then ignored.
- With `persistence.enabled=false` the volumes use an emptyDir — storage is ephemeral and is lost
when the pod restarts. Use only for testing.
-15
View File
@@ -13,9 +13,6 @@ data:
{{- if .Values.env.LOG_LEVEL }}
LOG_LEVEL: {{ .Values.env.LOG_LEVEL | quote }}
{{- end }}
{{- if .Values.env.TREK_WIKI_DIR }}
TREK_WIKI_DIR: {{ .Values.env.TREK_WIKI_DIR | quote }}
{{- end }}
{{- if .Values.env.ALLOWED_ORIGINS }}
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
{{- end }}
@@ -31,12 +28,6 @@ data:
{{- if .Values.env.COOKIE_SECURE }}
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
{{- end }}
{{- if .Values.env.SESSION_DURATION }}
SESSION_DURATION: {{ .Values.env.SESSION_DURATION | quote }}
{{- end }}
{{- if .Values.env.SESSION_DURATION_REMEMBER }}
SESSION_DURATION_REMEMBER: {{ .Values.env.SESSION_DURATION_REMEMBER | quote }}
{{- end }}
{{- if .Values.env.TRUST_PROXY }}
TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }}
{{- end }}
@@ -73,9 +64,3 @@ data:
{{- if .Values.env.MCP_RATE_LIMIT }}
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
{{- end }}
{{- if .Values.env.OVERPASS_URL }}
OVERPASS_URL: {{ .Values.env.OVERPASS_URL | quote }}
{{- end }}
{{- if .Values.env.OVERPASS_TIMEOUT_MS }}
OVERPASS_TIMEOUT_MS: {{ .Values.env.OVERPASS_TIMEOUT_MS | quote }}
{{- end }}
+2 -22
View File
@@ -6,12 +6,6 @@ metadata:
app: {{ include "trek.name" . }}
spec:
replicas: 1
# TREK is a single-writer SQLite app on a ReadWriteOnce PVC, so the default
# RollingUpdate would start a second pod holding the same volume before the old one
# exits — a Multi-Attach deadlock, or two processes on one travel.db. Recreate tears
# the old pod down first. Override to RollingUpdate only with a ReadWriteMany volume.
strategy:
type: {{ .Values.updateStrategy | default "Recreate" }}
selector:
matchLabels:
app: {{ include "trek.name" . }}
@@ -69,12 +63,6 @@ spec:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: OIDC_CLIENT_SECRET
optional: true
- name: UNSPLASH_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: UNSPLASH_ACCESS_KEY
optional: true
volumeMounts:
- name: data
mountPath: /app/data
@@ -94,16 +82,8 @@ spec:
periodSeconds: 10
volumes:
- name: data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ default (printf "%s-data" (include "trek.fullname" .)) .Values.persistence.data.existingClaim }}
{{- else }}
emptyDir: {}
{{- end }}
claimName: {{ include "trek.fullname" . }}-data
- name: uploads
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ default (printf "%s-uploads" (include "trek.fullname" .)) .Values.persistence.uploads.existingClaim }}
{{- else }}
emptyDir: {}
{{- end }}
claimName: {{ include "trek.fullname" . }}-uploads
+1 -17
View File
@@ -1,42 +1,26 @@
{{- if and .Values.persistence.enabled (not .Values.persistence.data.existingClaim) }}
{{- if .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "trek.fullname" . }}-data
labels:
app: {{ include "trek.name" . }}
{{- with .Values.persistence.data.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
- ReadWriteOnce
{{- with .Values.persistence.data.storageClassName }}
storageClassName: {{ . | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.data.size }}
{{- end }}
---
{{- if and .Values.persistence.enabled (not .Values.persistence.uploads.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "trek.fullname" . }}-uploads
labels:
app: {{ include "trek.name" . }}
{{- with .Values.persistence.uploads.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
- ReadWriteOnce
{{- with .Values.persistence.uploads.storageClassName }}
storageClassName: {{ . | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.uploads.size }}
-6
View File
@@ -17,9 +17,6 @@ data:
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }}
{{- end }}
{{- end }}
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
@@ -47,7 +44,4 @@ stringData:
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }}
{{- end }}
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
{{- end }}
{{- end }}
-37
View File
@@ -4,11 +4,6 @@ image:
# tag: latest
pullPolicy: IfNotPresent
# Deployment update strategy. Recreate is the safe default for the single-writer SQLite
# DB on a ReadWriteOnce volume (the old pod is torn down before the new one starts).
# Set to RollingUpdate only if you back the data volume with ReadWriteMany storage.
updateStrategy: Recreate
# Optional image pull secrets for private registries
imagePullSecrets: []
# - name: my-registry-secret
@@ -24,12 +19,6 @@ env:
# Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin).
# LOG_LEVEL: "info"
# "info" = concise user actions, "debug" = verbose details.
# TREK_WIKI_DIR: "/app/wiki"
# Where the in-app Help pages (/help) read their content from. Leave unset: the
# image ships the wiki at /app/wiki and finds it automatically, so Help matches
# the version you are running. Only set this to serve your own docs from a mounted
# volume. If the path does not exist, Help falls back to fetching the public GitHub
# wiki, which needs egress and tracks the latest release rather than your version.
# DEFAULT_LANGUAGE: "en"
# Default language on the login page for users with no saved preference.
# Browser/OS language is auto-detected first; this is the fallback when no match is found.
@@ -45,10 +34,6 @@ env:
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
# COOKIE_SECURE: "true"
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
# SESSION_DURATION: "24h"
# How long a login session stays valid when "Remember me" is unchecked (the default): trek_session JWT exp + a browser-session cookie. Accepts 1h, 12h, 7d, 30d, 90d. Defaults to 24h.
# SESSION_DURATION_REMEMBER: "30d"
# Session length when "Remember me" is ticked: a longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Defaults to 30d.
# TRUST_PROXY: "1"
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
# ALLOW_INTERNAL_NETWORK: "false"
@@ -78,12 +63,6 @@ env:
# Max MCP API requests per user per minute. Defaults to 300.
# MCP_MAX_SESSION_PER_USER: "20"
# Max concurrent MCP sessions per user. Defaults to 20.
# OVERPASS_URL: ""
# Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled
# public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable
# from the cluster (e.g. locked-down egress). Non-http(s) entries are ignored.
# OVERPASS_TIMEOUT_MS: "12000"
# Per-endpoint timeout (ms) for Overpass POI requests. Raise it for a slow self-hosted Overpass instance. Defaults to 12000.
# Secret environment variables stored in a Kubernetes Secret.
@@ -103,12 +82,6 @@ secretEnv:
ADMIN_PASSWORD: ""
# OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID.
OIDC_CLIENT_SECRET: ""
# Optional Unsplash Access Key for trip-cover and place-image search.
# Without one, TREK uses Unsplash's unauthenticated endpoint, which some
# datacenter/VPS IPs (including many Kubernetes clusters) are blocked from.
# Get a free key at https://unsplash.com/developers. Can also be set per-admin
# in Admin > Settings; this value overrides that. Leave empty to disable.
UNSPLASH_ACCESS_KEY: ""
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
generateEncryptionKey: false
@@ -118,21 +91,11 @@ existingSecret: ""
existingSecretKey: ENCRYPTION_KEY
persistence:
# When disabled, volumes fall back to an ephemeral emptyDir (data lost on pod restart).
enabled: true
data:
size: 1Gi
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
storageClassName: ""
# Bind an existing PVC. The other values (size, storageClassName, annotations) are then ignored.
existingClaim: ""
annotations: {}
uploads:
size: 1Gi
storageClassName: ""
# Specify an existing PVC to bind. The other values are then ignored.
existingClaim: ""
annotations: {}
resources:
requests:
-8
View File
@@ -1,8 +0,0 @@
# Playwright E2E (FE7)
e2e/.tmp/
test-results/
playwright-report/
playwright/.cache/
# vite-plugin-pwa dev output (devOptions.enabled)
dev-dist/
-27
View File
@@ -1,27 +0,0 @@
{
"printWidth": 120,
"useTabs": false,
"tabWidth": 2,
"trailingComma": "es5",
"semi": true,
"singleQuote": true,
"bracketSpacing": true,
"arrowParens": "always",
"jsxSingleQuote": false,
"bracketSameLine": false,
"endOfLine": "lf",
"plugins": [
"prettier-plugin-organize-imports",
"@trivago/prettier-plugin-sort-imports",
"prettier-plugin-tailwindcss"
],
"importOrder": [
"^[a-zA-Z]",
"^@/.*"
],
"importOrderSeparation": true,
"importOrderParserPlugins": [
"typescript",
"decorators-legacy"
]
}
-39
View File
@@ -1,39 +0,0 @@
import { test as setup, expect } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Relative to the config dir (client/), matching `storageState` in
// playwright.config.ts. Playwright runs from the client workspace root.
const stateFile = 'e2e/.tmp/state.json'
// Credentials match e2e/server-launch.mjs (ADMIN_EMAIL/ADMIN_PASSWORD). The
// seeded admin is created with must_change_password=1, so the first login goes
// through the forced change-password step before reaching the dashboard.
const EMAIL = 'e2e@trek.local'
const SEED_PW = 'E2eTest12345!'
const NEW_PW = 'E2eChanged12345!'
setup('authenticate the seeded admin (incl. forced password change)', async ({ page }) => {
await page.goto('/login')
await page.locator('input[type="email"]').fill(EMAIL)
await page.locator('input[type="password"]').fill(SEED_PW)
await page.locator('button[type="submit"]').click()
// must_change_password=1 → the change-password step renders two password
// fields (new + confirm). Selector-agnostic of the UI language.
const pw = page.locator('input[type="password"]')
await expect(pw).toHaveCount(2)
await pw.nth(0).fill(NEW_PW)
await pw.nth(1).fill(NEW_PW)
await page.locator('button[type="submit"]').click()
await page.waitForURL('**/dashboard', { timeout: 30_000 })
// Dismiss the first-run system-notice modal(s) — currently the thank-you /
// support modal, which has NO "OK" button (only CTAs + the X). The shared
// helper handles both notice shapes; dismissal is recorded server-side
// against this user, so clearing it here keeps it cleared for every
// authenticated flow in the run (shared test DB).
await dismissSystemNotices(page, 10_000)
await page.context().storageState({ path: stateFile })
})
-31
View File
@@ -1,31 +0,0 @@
import { test, expect } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Trip lifecycle (core): from the dashboard, open the new-trip modal, name the
// trip, submit, and confirm it shows up on the dashboard. Exercises the whole
// authenticated stack — dashboard → TripFormModal → POST /api/trips → store →
// re-render — against the real backend + isolated test DB.
test('create a trip and see it on the dashboard', async ({ page }) => {
await page.goto('/dashboard')
// The release notice greets a freshly seeded user and its backdrop eats the click below.
await dismissSystemNotices(page)
// The "+ New Trip" card is always rendered in the default (planned) filter.
await page.locator('.add-trip-card').click()
// Scope to the shared Modal (.trek-modal-backdrop — namespaced so content blockers
// don't hide a generic .modal-backdrop). Its form has no in-form submit button (the
// primary action lives in the footer), so click it explicitly rather than pressing
// Enter. The Create button is the slate primary button; Cancel is the bordered one.
const modal = page.locator('.trek-modal-backdrop')
await expect(modal).toBeVisible()
// Target Title by placeholder: the cover-image search inputs sit above it, so
// input[type=text].first() is the photo search box, not the field we want.
const title = `E2E Trip ${Date.now()}`
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
await modal.getByRole('button', { name: 'Create New Trip' }).click()
await expect(page.getByText(title).first()).toBeVisible({ timeout: 15_000 })
})
-10
View File
@@ -1,10 +0,0 @@
import { test, expect } from '@playwright/test'
// Authenticated smoke: the stored session lands on the dashboard and the
// app chrome (navbar) renders instead of bouncing back to /login.
test('authenticated session reaches the dashboard', async ({ page }) => {
await page.goto('/dashboard')
await expect(page).toHaveURL(/\/dashboard/)
// The shared Navbar shows the TREK brand once authenticated.
await expect(page.getByRole('img', { name: 'TREK' }).first()).toBeVisible()
})
-43
View File
@@ -1,43 +0,0 @@
import type { Page } from '@playwright/test'
/**
* Dismiss the system-notice modal(s) (SystemNoticeHost), which greet a freshly
* seeded user on first load and cover the dashboard — the backdrop swallows
* clicks aimed at anything underneath, `.add-trip-card` included.
*
* The host renders asynchronously (after the notices fetch), so wait for the
* notice dialog before deciding there is nothing to clear. Every lookup is
* scoped INSIDE the dialog — an unscoped /next/i can match dashboard buttons
* (carousel arrows) and satisfy the wait before the modal even mounts.
*
* A notice closes one of two ways depending on its shape:
* - CTA-bearing notices (e.g. the thank-you/support modal) only offer the
* X button (`aria-label="Dismiss"`), shown on the last page.
* - CTA-less notices show an "OK" button that pages forward and dismisses on
* the last page.
* Multi-page notices are paged through via the pager's Next button first.
* Dismissal is persisted server-side per user, so clearing once keeps it
* cleared for every later spec in the run (shared test DB).
*/
export async function dismissSystemNotices(page: Page, appearTimeoutMs = 3_000): Promise<void> {
const dialog = page.getByRole('dialog').first()
await dialog.waitFor({ state: 'visible', timeout: appearTimeoutMs }).catch(() => {})
// Clear up to a handful of queued notices.
for (let notice = 0; notice < 4 && (await dialog.isVisible().catch(() => false)); notice++) {
const next = dialog.getByRole('button', { name: /next/i })
for (let i = 0; i < 8 && (await next.isVisible().catch(() => false)); i++) {
if (!(await next.isEnabled().catch(() => false))) break
await next.click()
}
const dismiss = dialog.getByRole('button', { name: 'Dismiss', exact: true })
const ok = dialog.getByRole('button', { name: 'OK', exact: true })
if (await dismiss.isVisible().catch(() => false)) await dismiss.click()
else if (await ok.isVisible().catch(() => false)) await ok.click()
else break
// Exit animation + the next queued notice mounting.
await page.waitForTimeout(400)
}
await dialog.waitFor({ state: 'detached', timeout: 5_000 }).catch(() => {})
}
-79
View File
@@ -1,79 +0,0 @@
import { test, expect, devices } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Tablet regression guard for #1432 — the places list must scroll under a touch swipe.
//
// A tablet is a coarse-pointer device at a *desktop* viewport width, so the width-based
// "is this mobile" check that 3.2.1 shipped left `draggable` armed on iPad: the swipe
// became an HTML5 drag and raised the drop-to-import overlay instead of scrolling. Drag
// is now gated on `(pointer: coarse)` (useIsTouch), and only a real device context proves
// it — a jsdom unit test cannot express "coarse pointer at 834px".
//
// Needs WebKit (`npx playwright install webkit`, plus libmanette-0.2-0 and libwoff1 on
// Debian/Ubuntu). WebKit is the right engine here, not a nicety: every browser on iPadOS
// is WebKit underneath, which is why the reporter saw this in all three they tried.
test.use({ ...devices['iPad Pro 11'] })
test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) => {
await page.goto('/dashboard')
await dismissSystemNotices(page)
await page.locator('.add-trip-card').click()
const createBtn = page.getByRole('button', { name: 'Create New Trip' })
await expect(createBtn).toBeVisible()
const title = `iPad 1432 ${Date.now()}`
await page.getByPlaceholder('e.g. Summer in Japan').fill(title)
await createBtn.click()
await page.getByText(title).first().click()
await expect(page).toHaveURL(/\/trips\/\d+/)
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
const tripId = page.url().match(/\/trips\/(\d+)/)![1]
// Seed enough places for the list to overflow and actually need scrolling.
for (let i = 1; i <= 25; i++) {
const res = await page.request.post(`/api/trips/${tripId}/places`, {
data: { name: `Place ${i}`, lat: 48.85 + i * 0.01, lng: 2.35 + i * 0.01 },
})
expect(res.ok(), `seed place ${i}`).toBeTruthy()
}
await page.reload()
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
await expect(page.getByText('Place 1').first()).toBeVisible({ timeout: 20_000 })
// The context must really be the one from the bug report: coarse pointer, desktop
// width. If either is wrong, everything below proves nothing.
const env = await page.evaluate(() => ({
coarse: window.matchMedia('(pointer: coarse)').matches,
width: window.innerWidth,
}))
expect(env.coarse, 'iPad reports a coarse primary pointer').toBe(true)
expect(env.width, 'iPad sits above the 768px "mobile" breakpoint').toBeGreaterThanOrEqual(768)
// 1. Rows must not be draggable — a draggable row is what swallowed the scroll gesture.
const row = page.locator('div[draggable]').filter({ hasText: 'Place 1' }).first()
await expect(row).toHaveAttribute('draggable', 'false')
// 2. The list must scroll, and no drop-to-import overlay may appear.
const scroller = page.locator('div[draggable]').first().locator('xpath=ancestor::div[@class="trek-stagger"]')
const before = await scroller.evaluate(el => el.scrollTop)
const box = (await scroller.boundingBox())!
await page.touchscreen.tap(box.x + box.width / 2, box.y + 40)
await scroller.evaluate(el => el.scrollBy(0, 200))
const after = await scroller.evaluate(el => el.scrollTop)
expect(after, 'places list scrolled').toBeGreaterThan(before)
await expect(page.getByText('Drop to import')).toHaveCount(0)
// 3. Drag being off means the arrow buttons are the only reorder affordance left —
// they must be visible (they were opacity:0 above 767px).
const arrowOpacity = await page.evaluate(() => {
const el = document.querySelector('.reorder-buttons')
return el ? getComputedStyle(el).opacity : 'absent'
})
expect(['1', 'absent']).toContain(arrowOpacity)
// 4. The iPad must still get the desktop two-pane layout — isMobile stayed width-based.
await expect(page.locator('.leaflet-container')).toBeVisible()
})
-8
View File
@@ -1,8 +0,0 @@
import { test, expect } from '@playwright/test'
// Infra smoke + first unauthenticated flow: the app boots, the backend is
// reachable through the Vite proxy, and the login screen renders its form.
test('login screen renders with a password field', async ({ page }) => {
await page.goto('/login')
await expect(page.locator('input[type="password"]')).toBeVisible()
})
-61
View File
@@ -1,61 +0,0 @@
import { test, expect } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// The day-plan reorder arrows are hover-revealed on desktop. The rule that did that was
// dead for a long time — it targeted `.place-row .reorder-btns`, neither of which exists
// (the component renders `.reorder-buttons` inside an unclassed row), so the buttons sat
// at opacity:0 with no way to reveal them.
//
// That is not merely "invisible": opacity:0 still hit-tests, so every itinerary row and
// note carried an invisible, fully clickable target that silently reordered the trip.
// These cases pin both halves — hidden means non-interactive, hover means visible.
test('desktop: reorder arrows are hidden-and-inert until the row is hovered', async ({ page }) => {
await page.goto('/dashboard')
await dismissSystemNotices(page)
await page.locator('.add-trip-card').click()
const modal = page.locator('.trek-modal-backdrop')
await expect(modal).toBeVisible()
const title = `Reorder ${Date.now()}`
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
await modal.getByRole('button', { name: 'Create New Trip' }).click()
await page.getByText(title).first().click()
await expect(page).toHaveURL(/\/trips\/\d+/)
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
// Two places on day 1, so the day plan renders rows carrying reorder arrows.
const tripId = page.url().match(/\/trips\/(\d+)/)![1]
const daysRes = await (await page.request.get(`/api/trips/${tripId}/days`)).json()
const dayId = (daysRes.days ?? daysRes)[0].id
for (const name of ['Alpha', 'Beta']) {
const res = await page.request.post(`/api/trips/${tripId}/places`, {
data: { name, lat: 48.85, lng: 2.35 },
})
const body = await res.json()
await page.request.post(`/api/trips/${tripId}/days/${dayId}/assignments`, {
data: { place_id: body.place?.id ?? body.id },
})
}
await page.reload()
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
const row = page.locator('.dp-row').filter({ hasText: 'Alpha' }).first()
await expect(row).toBeVisible({ timeout: 20_000 })
const arrows = row.locator('.reorder-buttons')
// Unhovered: invisible AND inert — a click there must not land on the button.
const idle = await arrows.evaluate(el => {
const cs = getComputedStyle(el)
const r = el.getBoundingClientRect()
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)
return { opacity: cs.opacity, hitsArrow: !!hit?.closest('.reorder-buttons') }
})
expect(idle.opacity, 'arrows hidden until hover').toBe('0')
expect(idle.hitsArrow, 'hidden arrows must not swallow clicks').toBe(false)
// Hovered: revealed and clickable.
await row.hover()
await expect(arrows).toHaveCSS('opacity', '1')
await expect(arrows).toHaveCSS('pointer-events', 'auto')
})
-26
View File
@@ -1,26 +0,0 @@
import { test, expect } from './shot'
/**
* Unauthenticated surfaces. `storageState: undefined` drops the admin session
* this project otherwise inherits, so these render as a logged-out visitor sees
* them — which is the entire point of the login and registration pages.
*/
test.use({ storageState: undefined })
test('login page', async ({ page, shot }) => {
await page.goto('/login')
await expect(page.locator('input[type="email"]')).toBeVisible()
await shot.page_('Login')
})
test('registration page', async ({ page, shot }) => {
await page.goto('/register')
await page.waitForTimeout(500)
await shot.page_('Registration')
})
test('forgot password', async ({ page, shot }) => {
await page.goto('/forgot-password')
await page.waitForTimeout(500)
await shot.page_('PasswordReset')
})
-69
View File
@@ -1,69 +0,0 @@
import { test, clearNotices, expect } from './shot'
import type { Page, Locator } from '@playwright/test'
import { readFileSync } from 'node:fs'
import path from 'node:path'
/**
* Collab surfaces, one capture each.
*
* Until now a single Collab.png illustrated four different wiki pages — chat,
* notes, polls and the What's Next widget — so at most one of them showed the
* feature its page described.
*
* The Collab view is NOT tabbed: CollabPanel renders chat in a fixed 380px left
* column and the other panels beside it, all visible at once (CollabPanel.tsx:94).
* So each capture targets its own card element rather than clicking a tab.
*/
const seed = JSON.parse(
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
) as { tripId: number }
/**
* The panel card containing a given piece of seeded content — see cardClass in
* CollabPanel.tsx:20.
*
* Matching on content rather than the panel heading is deliberate: the headings
* render uppercase through CSS while the DOM text is "Notes" / "Polls", and
* those same words also appear in the mobile tab bar, so a heading match is both
* wrong-cased and ambiguous.
*/
function card(page: Page, contains: string): Locator {
return page
.locator('div.bg-surface-card.rounded-2xl')
.filter({ hasText: contains })
.last()
}
test.beforeEach(async ({ page }) => {
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
await page.getByRole('button', { name: 'Collab', exact: true }).first().click()
await page.waitForTimeout(1200)
})
test('collab chat', async ({ page, shot }) => {
// Seeded as three different people; a single-voice log would misrepresent it.
// The chat auto-scrolls to the newest message, so assert on the last line of
// the seeded conversation rather than the first — the first is off-screen.
await expect(page.getByText('kaiseki', { exact: false }).first()).toBeVisible()
await shot.element('CollabChat', card(page, 'kaiseki'))
})
test('collab notes', async ({ page, shot }) => {
await expect(page.getByText('Rail passes', { exact: false })).toBeVisible()
await shot.element('CollabNotes', card(page, 'Rail passes'))
})
test('collab polls', async ({ page, shot }) => {
await expect(page.getByText('free for Nara', { exact: false })).toBeVisible()
await shot.element('CollabPolls', card(page, 'free for Nara'))
})
test("what's next widget", async ({ page, shot }) => {
await shot.element('WhatsNext', card(page, "What's Next"))
})
test('collab overview', async ({ page, shot }) => {
await shot.page_('Collab')
})
-88
View File
@@ -1,88 +0,0 @@
import { test, clearNotices, expect } from './shot'
import { readFileSync } from 'node:fs'
import path from 'node:path'
/**
* Detail pages and the surfaces that need a couple of clicks to reach.
*
* Each capture asserts something specific to the surface before shooting, so a
* navigation that quietly lands on a fallback (or an addon that is off) fails
* the run instead of producing a screenshot of the wrong screen.
*/
const seed = JSON.parse(
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
) as { tripId: number; collectionId?: number; journeyId?: number }
test('collection detail', async ({ page, shot }) => {
test.skip(!seed.collectionId, 'collections addon unavailable during seed')
await page.goto(`/collections/${seed.collectionId}`)
await clearNotices(page)
await shot.page_('CollectionDetail')
})
test('journey detail', async ({ page, shot }) => {
test.skip(!seed.journeyId, 'journey addon unavailable during seed')
await page.goto(`/journey/${seed.journeyId}`)
await clearNotices(page)
await shot.page_('JourneyDetail')
})
test('mcp access — admin', async ({ page, shot }) => {
await page.goto('/admin')
await clearNotices(page)
await page.getByRole('button', { name: 'MCP Access', exact: true }).first().click()
await page.waitForTimeout(700)
await shot.page_('MCPAccess')
})
test('two-factor setup', async ({ page, shot }) => {
await page.goto('/settings')
await clearNotices(page)
await page.getByRole('button', { name: 'Account', exact: true }).first().click()
await page.waitForTimeout(600)
// The enrolment flow is behind a button whose label varies with state; match
// loosely and fall back to capturing the tab itself.
const enable = page.getByRole('button', { name: /two-factor|2fa|authenticator/i }).first()
if (await enable.isVisible().catch(() => false)) {
await enable.click()
await page.waitForTimeout(900)
}
await shot.page_('2FA')
})
/**
* Settle-up.
*
* WARNING for anyone extending this file: the "Settle up" button in the Costs
* toolbar is not a view — it RECORDS the settling transfers. An earlier version
* of this test clicked it, which zeroed every balance and left the capture
* showing "Everyone's square". Because all screenshot specs share one database
* and this file sorts before planner.shot.ts, it also poisoned Costs.png in the
* same run.
*
* Screenshot specs must not mutate state. Capture the "Add payment" dialog
* instead — same surface, no side effect — and close it again.
*/
test('costs — record a settle-up payment', async ({ page, shot }) => {
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
await page.getByRole('button', { name: 'Costs', exact: true }).first().click()
await page.waitForTimeout(800)
const addPayment = page.getByRole('button', { name: /add payment/i }).first()
test.skip(!(await addPayment.isVisible().catch(() => false)), 'no add-payment entry point rendered')
await addPayment.click()
await page.waitForTimeout(700)
const modal = page.locator('.trek-modal-backdrop > div').first()
await expect(modal).toBeVisible()
await shot.element('CostsSettleUp', modal)
})
test('trip files', async ({ page, shot }) => {
await page.goto(`/trips/${seed.tripId}/files`)
await clearNotices(page)
await expect(page).toHaveURL(/files/)
await shot.page_('Documents')
})
-42
View File
@@ -1,42 +0,0 @@
import { test, clearNotices, expect } from './shot'
import { readFileSync } from 'node:fs'
import path from 'node:path'
/**
* Modals and dialogs.
*
* Captured as element screenshots (not full page) so the wiki gets the dialog
* itself rather than a dimmed backdrop with a small box in the middle. Each one
* asserts the dialog is actually open first — a missed click would otherwise
* silently produce a screenshot of the page behind it.
*/
const seed = JSON.parse(
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
) as { tripId: number }
/**
* The shared Modal (client/src/components/shared/Modal.tsx) sets neither
* role="dialog" nor aria-modal, so there is no accessible role to query — the
* backdrop class is the only stable hook. Target its child, which is the panel
* itself, so the capture excludes the dimmed backdrop.
*/
function dialog(page: import('@playwright/test').Page) {
return page.locator('.trek-modal-backdrop > div').first()
}
test('create trip modal — with the new currency field', async ({ page, shot }) => {
await page.goto('/dashboard')
await clearNotices(page)
await page.getByRole('button', { name: /new trip/i }).first().click()
await expect(dialog(page)).toBeVisible()
await shot.element('TripCreate', dialog(page))
})
test('share dialog', async ({ page, shot }) => {
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
await page.getByRole('button', { name: /share/i }).first().click()
await expect(dialog(page)).toBeVisible()
await shot.element('Share', dialog(page))
})
-67
View File
@@ -1,67 +0,0 @@
import { test, clearNotices } from './shot'
import { readFileSync } from 'node:fs'
import path from 'node:path'
/**
* Top-level navigable surfaces. One capture per route; anything that needs a
* dialog opened or a tab clicked lives in its own spec so a failure there
* cannot take these down with it.
*
* Names are the target filenames in wiki/assets/ — see docs/screenshot-map.md
* for which wiki page consumes which file.
*/
const seed = JSON.parse(
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
) as { tripId: number; collectionId?: number; journeyId?: number }
test.beforeEach(async ({ page }) => {
await page.goto('/dashboard')
await clearNotices(page)
})
test('dashboard', async ({ page, shot }) => {
await page.goto('/dashboard')
await clearNotices(page)
await shot.page_('DashboardWidgets')
})
test('trip planner', async ({ page, shot }) => {
await page.goto(`/trips/${seed.tripId}`)
await shot.page_('TripPlanner')
})
test('atlas', async ({ page, shot }) => {
await page.goto('/atlas')
await shot.page_('Atlas')
})
test('vacay', async ({ page, shot }) => {
await page.goto('/vacay')
await shot.page_('Vacay')
})
test('collections', async ({ page, shot }) => {
await page.goto('/collections')
await shot.page_('Collections')
})
test('journey', async ({ page, shot }) => {
await page.goto('/journey')
await shot.page_('Journey')
})
test('notifications inbox', async ({ page, shot }) => {
await page.goto('/notifications')
await shot.page_('NotificationsInbox')
})
test('in-app help', async ({ page, shot }) => {
await page.goto('/help')
await shot.page_('HelpInApp')
})
test('files', async ({ page, shot }) => {
await page.goto(`/trips/${seed.tripId}/files`)
await shot.page_('Files')
})
-47
View File
@@ -1,47 +0,0 @@
import { test, clearNotices } from './shot'
import { readFileSync } from 'node:fs'
import path from 'node:path'
/**
* Trip-planner tabs and dialogs.
*
* Tabs are reached by their visible label rather than a test id, deliberately:
* if a label is renamed (as Budget → Costs was in 3.3.0) this run fails loudly
* instead of silently capturing the wrong panel — which is exactly how the
* current wiki ended up with screenshots the text contradicts.
*/
const seed = JSON.parse(
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
) as { tripId: number }
test.beforeEach(async ({ page }) => {
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
})
async function openTab(page: import('@playwright/test').Page, label: string) {
await page.getByRole('button', { name: label, exact: true }).first().click()
await page.waitForTimeout(700)
}
test('costs panel', async ({ page, shot }) => {
await openTab(page, 'Costs')
await shot.page_('Costs')
})
test('lists — packing', async ({ page, shot }) => {
await openTab(page, 'Lists')
await shot.page_('PackingList')
})
test('transports', async ({ page, shot }) => {
await openTab(page, 'Transports')
await shot.page_('Transports')
})
test('bookings', async ({ page, shot }) => {
await openTab(page, 'Book')
await shot.page_('Bookings')
})
-59
View File
@@ -1,59 +0,0 @@
// Moves captured screenshots from the staging directory into wiki/assets/,
// downscaling and re-encoding on the way.
//
// Captures are taken at 1440px CSS width with deviceScaleFactor 2, i.e. 2880px
// of raw pixels. The wiki renders images at roughly 8001000px, so shipping
// 2880px costs ~10x the bytes for detail nobody sees — that is how the existing
// assets reached 26 MB (one GIF alone is 9.1 MB). 1600px keeps the image sharp
// on HiDPI displays at the size it is actually shown.
//
// Usage: node e2e/screenshots/promote.mjs [--dry]
import sharp from 'sharp'
import { readdirSync, mkdirSync, statSync } from 'node:fs'
import path from 'node:path'
const SRC = path.join(process.cwd(), 'e2e', '.tmp', 'shots')
const DEST = path.join(process.cwd(), '..', 'wiki', 'assets')
const MAX_WIDTH = 1600
const dry = process.argv.includes('--dry')
mkdirSync(DEST, { recursive: true })
const files = readdirSync(SRC).filter(f => f.endsWith('.png'))
if (!files.length) {
console.error(`No screenshots in ${SRC} — run \`npm run shots\` first.`)
process.exit(1)
}
let before = 0
let after = 0
for (const file of files.sort()) {
const src = path.join(SRC, file)
const dest = path.join(DEST, file)
const srcBytes = statSync(src).size
before += srcBytes
const img = sharp(src)
const { width } = await img.metadata()
const pipeline = sharp(src)
.resize({ width: Math.min(width ?? MAX_WIDTH, MAX_WIDTH), withoutEnlargement: true })
.png({ compressionLevel: 9, effort: 10 })
const buf = await pipeline.toBuffer()
after += buf.length
const pct = Math.round((1 - buf.length / srcBytes) * 100)
console.log(
`${dry ? '[dry] ' : ''}${file.padEnd(28)} ${kb(srcBytes).padStart(8)}${kb(buf.length).padStart(8)} (-${pct}%)`,
)
if (!dry) await sharp(buf).toFile(dest)
}
console.log(`\n${files.length} files: ${kb(before)}${kb(after)} (-${Math.round((1 - after / before) * 100)}%)`)
if (dry) console.log('Dry run — nothing written. Drop --dry to promote into wiki/assets/.')
function kb(bytes) {
return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`
}
-37
View File
@@ -1,37 +0,0 @@
import { test as setup, expect } from '@playwright/test'
import { writeFileSync, mkdirSync } from 'node:fs'
import path from 'node:path'
import { seedDemoData } from './seed'
/**
* Populates the throwaway E2E database with the demo trip before any screenshot
* runs. Its own Playwright project so it executes exactly once, after `setup`
* (which produces the authenticated storageState) and before `screenshots`.
*
* The resulting ids are written to disk because Playwright projects do not
* share memory — the capture specs read them back.
*/
setup('seed the demo trip', async ({ page, playwright }) => {
// page.request carries the storageState cookie, so this is authenticated as
// the admin. The factory hands the seeder throwaway contexts for the other
// members — see the comment in seed.ts on why they must not share one.
const result = await seedDemoData(page.request, token =>
playwright.request.newContext({
baseURL: 'http://localhost:5173',
// MUST be explicit: newContext otherwise picks up the project's
// storageState, i.e. the admin's trek_session cookie — and the server
// reads the cookie BEFORE the Authorization header
// (server/src/middleware/auth.ts:9), so every "member" write would be
// recorded as the admin while still returning 200.
storageState: undefined,
extraHTTPHeaders: token ? { Authorization: `Bearer ${token}` } : {},
}),
)
expect(result.tripId, 'trip was created').toBeTruthy()
expect(result.placeIds.length, 'places were created').toBeGreaterThan(0)
const dir = path.join(process.cwd(), 'e2e', '.tmp')
mkdirSync(dir, { recursive: true })
writeFileSync(path.join(dir, 'seed.json'), JSON.stringify(result, null, 2))
})
-347
View File
@@ -1,347 +0,0 @@
import path from 'node:path'
import type { APIRequestContext } from '@playwright/test'
/**
* Demo data for the documentation screenshots.
*
* Seeded over the REST API (not the DB) so it exercises the same paths a real
* user would and stays honest about validation. The session cookie comes from
* the storageState that auth.setup.ts writes, so `page.request` is already
* authenticated as the seeded admin.
*
* Design notes that matter for the screenshots:
* - The trip is in **JPY**, deliberately. A EUR trip hides the entire v3.4.0
* currency rework (per-trip currency, frozen FX rates, foreign-currency
* settle-up) — the reader would see nothing new.
* - Two extra members exist so splits, avatars and sharing tiers render with
* real names instead of a lonely single-user state.
* - Dates sit ~2 months out so "upcoming" surfaces (What's Next, reservations)
* have something to show.
*/
const TRIP = {
title: 'Autumn in Japan',
description: 'Two weeks chasing momiji season from Tokyo down to Kyoto.',
start_date: '2026-09-12',
end_date: '2026-09-21',
currency: 'JPY',
reminder_days: 3,
}
const MEMBERS = [
{ username: 'mira', email: 'mira@example.com', password: 'DemoSeed12345!', role: 'user' },
{ username: 'jonas', email: 'jonas@example.com', password: 'DemoSeed12345!', role: 'user' },
]
/** Real coordinates — the map surfaces are a big part of what we're capturing. */
const PLACES = [
{ name: 'Senso-ji Temple', lat: 35.7148, lng: 139.7967, address: '2-3-1 Asakusa, Taito City, Tokyo',
description: "Tokyo's oldest temple, approached through the Nakamise shopping street.",
notes: 'Go before 08:00 — the gate is empty and the light is better.',
duration_minutes: 90, price: 0, currency: 'JPY', day: 0 },
{ name: 'teamLab Planets', lat: 35.6486, lng: 139.7900, address: '6-1-16 Toyosu, Koto City, Tokyo',
description: 'Immersive digital art museum you walk through barefoot.',
notes: 'Timed entry — book at least a week ahead.',
duration_minutes: 120, price: 3800, currency: 'JPY', day: 0 },
{ name: 'Shibuya Crossing', lat: 35.6595, lng: 139.7005, address: 'Shibuya City, Tokyo',
description: 'The scramble. Best viewed from the Shibuya Sky observation deck.',
duration_minutes: 45, price: 0, currency: 'JPY', day: 1 },
{ name: 'Meiji Jingu', lat: 35.6764, lng: 139.6993, address: '1-1 Yoyogikamizonocho, Shibuya City, Tokyo',
description: 'Forest shrine in the middle of the city.',
duration_minutes: 75, price: 0, currency: 'JPY', day: 1 },
{ name: 'Fushimi Inari Taisha', lat: 34.9671, lng: 135.7727, address: '68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto',
description: 'Thousands of vermilion torii gates climbing Mount Inari.',
notes: 'The crowds thin out after the first 20 minutes of climbing.',
duration_minutes: 150, price: 0, currency: 'JPY', day: 4 },
{ name: 'Arashiyama Bamboo Grove', lat: 35.0170, lng: 135.6716, address: 'Ukyo Ward, Kyoto',
description: 'Bamboo path leading to the Okochi Sanso villa gardens.',
duration_minutes: 60, price: 0, currency: 'JPY', day: 5 },
{ name: 'Nishiki Market', lat: 35.0050, lng: 135.7649, address: 'Nakagyo Ward, Kyoto',
description: "Five covered blocks of food stalls — 'Kyoto's kitchen'.",
notes: 'Come hungry. Try the tamagoyaki.',
duration_minutes: 90, price: 2500, currency: 'JPY', day: 5 },
]
const EXPENSES = [
{ name: 'Flights FRA → HND', category: 'transport', total_price: 890, currency: 'EUR',
expense_date: '2026-09-12', note: 'Booked with miles, taxes only.' },
{ name: 'Ryokan in Hakone', category: 'accommodation', total_price: 48000, currency: 'JPY',
expense_date: '2026-09-15', note: '2 nights, kaiseki dinner included.' },
{ name: 'JR Pass (14 days)', category: 'transport', total_price: 80000, currency: 'JPY',
expense_date: '2026-09-12', note: 'Green car, activated on arrival.' },
{ name: 'teamLab Planets tickets', category: 'activities', total_price: 11400, currency: 'JPY',
expense_date: '2026-09-13' },
{ name: 'Dinner at Nishiki', category: 'food', total_price: 7200, currency: 'JPY',
expense_date: '2026-09-17' },
]
const PACKING = [
{ category: 'Documents', items: ['Passport', 'JR Pass voucher', 'Travel insurance'] },
{ category: 'Clothing', items: ['Rain jacket', 'Walking shoes', 'Light layers'] },
{ category: 'Electronics', items: ['Type-A adapter', 'Power bank', 'Camera'] },
]
const TODOS = [
{ name: 'Book teamLab Planets slot', category: 'Before departure', due_date: '2026-08-15', priority: 2 },
{ name: 'Activate JR Pass', category: 'On arrival', due_date: '2026-09-12', priority: 1 },
{ name: 'Reserve ryokan dinner', category: 'Before departure', due_date: '2026-08-20' },
]
export interface SeedResult {
tripId: number
memberIds: number[]
dayIds: number[]
placeIds: number[]
collectionId?: number
journeyId?: number
}
/** Throws with the response body on failure — a silent 4xx here would produce
* a screenshot of an empty screen, which is worse than a loud crash. */
async function call<T>(api: APIRequestContext, method: 'post' | 'put' | 'get' | 'patch',
path: string, body?: unknown): Promise<T> {
const res = await api[method](path, body === undefined ? {} : { data: body })
if (!res.ok()) {
throw new Error(`${method.toUpperCase()} ${path}${res.status()}\n${await res.text()}`)
}
return (await res.json()) as T
}
export type ContextFactory = (token?: string) => Promise<APIRequestContext>
export async function seedDemoData(
api: APIRequestContext,
newContext?: ContextFactory,
): Promise<SeedResult> {
// 1. Addons first — the Collections and Journey guards run ahead of auth, so
// every later call to those modules 403s until these are flipped.
for (const id of ['collections', 'journey', 'packing', 'budget', 'atlas', 'vacay', 'mcp', 'documents', 'collab']) {
await call(api, 'put', `/api/admin/addons/${id}`, { enabled: true })
}
await call(api, 'put', '/api/admin/bag-tracking', { enabled: true }).catch(() => {})
// 1b. Units, pinned explicitly so the screenshots don't silently change meaning
// when a default does. They match the current defaults (ba3733da made
// celsius/metric/24h consistent across the store and the settings UI) —
// stating them here keeps the captures reproducible either way.
await call(api, 'post', '/api/settings/bulk', {
settings: { temperature_unit: 'celsius', distance_unit: 'metric' },
})
// 2. Extra members. Ignore 409 so a re-run against a warm DB still works.
const memberIds: number[] = []
for (const m of MEMBERS) {
const res = await api.post('/api/admin/users', { data: m })
if (res.ok()) {
const { user } = (await res.json()) as { user: { id: number } }
memberIds.push(user.id)
} else if (res.status() !== 409) {
throw new Error(`create user ${m.username}${res.status()}\n${await res.text()}`)
}
}
// 3. The trip, in JPY.
const { trip } = await call<{ trip: { id: number } }>(api, 'post', '/api/trips', TRIP)
const tripId = trip.id
for (const m of MEMBERS) {
await call(api, 'post', `/api/trips/${tripId}/members`, { identifier: m.email }).catch(() => {})
}
// 4. Days are auto-generated by trip creation — read them back for assignment.
const days = await call<Array<{ id: number }> | { days: Array<{ id: number }> }>(
api, 'get', `/api/trips/${tripId}/days`)
const dayIds = (Array.isArray(days) ? days : days.days).map(d => d.id)
// 5. Places, then pin each onto its day.
const placeIds: number[] = []
for (const p of PLACES) {
const { day, ...payload } = p
const { place } = await call<{ place: { id: number } }>(
api, 'post', `/api/trips/${tripId}/places`, payload)
placeIds.push(place.id)
const dayId = dayIds[day]
if (dayId) {
await call(api, 'post', `/api/trips/${tripId}/days/${dayId}/assignments`,
{ place_id: place.id }).catch(() => {})
}
}
// 6. A day note, so the itinerary shows more than places.
if (dayIds[0]) {
await call(api, 'post', `/api/trips/${tripId}/days/${dayIds[0]}/notes`, {
text: 'Pick up the JR Pass at the airport counter before taking the train in.',
time: '08:15', icon: 'train',
}).catch(() => {})
}
// 7. Costs. Split across everyone so the settle-up view has real balances.
// NOTE: never send exchange_rate — the server freezes the FX rate itself,
// and a hand-supplied one fights the settlement maths.
const allMembers = [1, ...memberIds]
for (const e of EXPENSES) {
await call(api, 'post', `/api/trips/${tripId}/budget`, {
...e,
payers: [{ user_id: 1, amount: e.total_price }],
member_ids: allMembers,
}).catch(() => {})
}
// A foreign-currency settle-up payment — the v3.4.0 feature worth showing.
if (memberIds[0]) {
await call(api, 'post', `/api/trips/${tripId}/budget/settlements`, {
from_user_id: memberIds[0], to_user_id: 1, amount: 120, currency: 'EUR',
}).catch(() => {})
}
// 8. Packing — category is free text on the item, there is no category resource.
for (const group of PACKING) {
for (const name of group.items) {
await call(api, 'post', `/api/trips/${tripId}/packing`, {
name, category: group.category, visibility: 'common',
}).catch(() => {})
}
}
for (const t of TODOS) {
await call(api, 'post', `/api/trips/${tripId}/todo`, t).catch(() => {})
}
// 9. A multi-leg flight. Coordinates are mandatory — endpoints without them
// are silently dropped by the server, leaving a booking with no route.
await call(api, 'post', `/api/trips/${tripId}/reservations`, {
title: 'LH716 FRA → HND',
type: 'flight',
reservation_time: '2026-09-12T13:05:00',
reservation_end_time: '2026-09-13T08:25:00',
confirmation_number: 'X7K2QP',
status: 'confirmed',
location: 'Frankfurt Airport',
metadata: { airline: 'Lufthansa', flight_number: 'LH716',
departure_airport: 'FRA', arrival_airport: 'HND' },
endpoints: [
{ role: 'from', sequence: 0, name: 'Frankfurt Airport', code: 'FRA',
lat: 50.0379, lng: 8.5622, timezone: 'Europe/Berlin',
local_date: '2026-09-12', local_time: '13:05' },
{ role: 'to', sequence: 1, name: 'Tokyo Haneda', code: 'HND',
lat: 35.5494, lng: 139.7798, timezone: 'Asia/Tokyo',
local_date: '2026-09-13', local_time: '08:25' },
],
}).catch(() => {})
// 10. A collection, populated from the trip's own places.
let collectionId: number | undefined
try {
const created = await call<{ id: number } | { collection: { id: number } }>(
api, 'post', '/api/addons/collections',
{ name: 'Kyoto shortlist', description: 'Places we want to reach on the second week.',
color: '#ef4444', icon: 'MapPin' })
collectionId = 'id' in created ? created.id : created.collection.id
for (const placeId of placeIds.slice(4)) {
await call(api, 'post', '/api/addons/collections/places/from-trip', {
collection_id: collectionId, source_trip_id: tripId, source_place_id: placeId, force: true,
}).catch(() => {})
}
} catch { /* collections addon unavailable — screenshots for it will be skipped */ }
// 11. Journey. Entries are generated server-side from the trip, then filled in.
let journeyId: number | undefined
try {
const j = await call<{ id: number } | { journey: { id: number } }>(
api, 'post', '/api/journeys',
{ title: 'Autumn in Japan', subtitle: 'Momiji season, Tokyo to Kyoto', trip_ids: [tripId] })
journeyId = 'id' in j ? j.id : j.journey.id
} catch { /* journey addon unavailable */ }
// 11b. Collab: chat, notes and polls.
//
// Chat is only convincing with more than one voice, and every collab
// write is attributed to the acting user — so messages and votes are
// posted as the members themselves, via their own bearer tokens, not as
// the admin. A single-speaker chat log would misrepresent the feature.
// Each member gets its OWN request context. Logging in through the shared
// one would set the trek_session cookie on it, and extractToken()
// (server/src/middleware/auth.ts:9) reads the cookie BEFORE the
// Authorization header — so every later write, including the admin's,
// would silently be attributed to whoever logged in last.
const members: Record<string, APIRequestContext> = {}
for (const m of MEMBERS) {
if (!newContext) break
const anon = await newContext()
const res = await anon.post('/api/auth/login', { data: { email: m.email, password: m.password } })
if (!res.ok()) { await anon.dispose(); continue }
const { token } = (await res.json()) as { token?: string }
await anon.dispose()
if (token) members[m.username] = await newContext(token)
}
/** The member's own context, or the admin's as a visible fallback. */
const as = (username: string): APIRequestContext => members[username] ?? api
const collab = `/api/trips/${tripId}/collab`
for (const n of [
{ title: 'Rail passes', category: 'Transport', color: '#3b82f6',
content: 'The 14-day JR Pass covers the TokyoKyoto legs. Activate it at the airport counter on arrival, not before.' },
{ title: 'Ryokan etiquette', category: 'Accommodation', color: '#ef4444',
content: 'Shoes off at the entrance, yukata for dinner. Dinner is served at 18:30 sharp — being late is genuinely rude.' },
{ title: 'Rainy-day alternatives', category: 'Ideas', color: '#22c55e',
content: 'teamLab Planets, the Kyoto Railway Museum and Nishiki Market all work in bad weather.' },
]) {
await api.post(`${collab}/notes`, { data: n }).catch(() => {})
}
const pollRes = await api.post(`${collab}/polls`, {
data: {
question: 'Which day should we keep free for Nara?',
options: ['Wed, Sep 16', 'Thu, Sep 17', 'Sat, Sep 19'],
multiple: false,
},
})
if (pollRes.ok()) {
const { poll } = (await pollRes.json()) as { poll: { id: number | string } }
await api.post(`${collab}/polls/${poll.id}/vote`, { data: { option_index: 1 } }).catch(() => {})
await as('mira').post(`${collab}/polls/${poll.id}/vote`, { data: { option_index: 1 } }).catch(() => {})
await as('jonas').post(`${collab}/polls/${poll.id}/vote`, { data: { option_index: 2 } }).catch(() => {})
}
await api.post(`${collab}/polls`, {
data: { question: 'Ryokan or city hotel in Hakone?', options: ['Ryokan with onsen', 'City hotel'], multiple: false },
}).catch(() => {})
const conversation: Array<[string, string]> = [
['admin', 'Flights are booked — we land at Haneda 08:25 on the 13th.'],
['mira', 'Nice. Should we go straight to the hotel or drop bags and head out?'],
['jonas', 'Drop bags. I want to be at Senso-ji before the crowds.'],
['admin', "Agreed. I've put it on day 1 with a note to go before 08:00."],
['mira', 'Booked the teamLab slot for the 13th, 14:00. Tickets are in the Files tab.'],
['jonas', 'Do we need to reserve the ryokan dinner separately?'],
['admin', "It's included — kaiseki, 18:30. Added it to the to-dos so we don't forget to confirm."],
]
for (const [who, text] of conversation) {
const ctx = who === 'admin' ? api : as(who)
await ctx.post(`${collab}/messages`, { data: { text } }).catch(() => {})
}
for (const ctx of Object.values(members)) await ctx.dispose()
// 12. Plugins, installed from the community registry.
//
// Registry install is the ONLY path that produces a representative
// screenshot. Dev-link and sideload both stamp the plugin card with a
// badge ("Dev-Link" / "Sideloaded", AdminPluginsPanel.tsx:307,361) that no
// ordinary install shows, and TREK_PLUGINS_DEV_LINK additionally reveals a
// "Link a local plugin" row in the panel. Documenting either would show
// readers a UI they will never have.
//
// Needs network. If the registry is unreachable the plugin screenshots are
// skipped loudly rather than silently captured in a misleading state.
for (const id of ['koffi', 'trip-doctor']) {
const res = await api.post('/api/admin/plugins/install', { data: { id } })
if (!res.ok()) {
console.log(`PLUGIN INSTALL FAILED ${id}${res.status()} ${await res.text()}`)
continue
}
await api.post(`/api/admin/plugins/${id}/activate`, { data: {} })
}
return { tripId, memberIds, dayIds, placeIds, collectionId, journeyId }
}
-105
View File
@@ -1,105 +0,0 @@
import { test, clearNotices } from './shot'
import type { Page } from '@playwright/test'
/**
* Settings and Admin tabs.
*
* Both pages use the shared PageSidebar with client-side tab state (no URL
* segment per tab), so each capture clicks its way in. Labels come from
* shared/src/i18n/en — note "General" is the tab the wiki still calls
* "Display", which is one of the corrections this screenshot run supports.
*/
async function openSidebarTab(page: Page, label: string) {
await page.getByRole('button', { name: label, exact: true }).first().click()
await page.waitForTimeout(600)
}
test.describe('user settings', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/settings')
await clearNotices(page)
})
// Filename kept as UsrSettings.png — the wiki already references it.
test('general tab', async ({ page, shot }) => {
await openSidebarTab(page, 'General')
await shot.page_('UsrSettings')
})
test('appearance tab', async ({ page, shot }) => {
await openSidebarTab(page, 'Appearance')
await shot.page_('UsrSettingsAppearance')
})
test('map tab', async ({ page, shot }) => {
await openSidebarTab(page, 'Map')
await shot.page_('UsrSettingsMap')
})
test('notifications tab', async ({ page, shot }) => {
await openSidebarTab(page, 'Notifications')
await shot.page_('NotifSettings')
})
test('offline tab', async ({ page, shot }) => {
await openSidebarTab(page, 'Offline')
await shot.page_('SettingsOffline')
})
test('account tab', async ({ page, shot }) => {
await openSidebarTab(page, 'Account')
await shot.page_('SettingsAccount')
})
})
test.describe('admin panel', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/admin')
await clearNotices(page)
})
test('users', async ({ page, shot }) => {
await openSidebarTab(page, 'Users')
await shot.page_('UsersAndInvites')
})
test('user defaults', async ({ page, shot }) => {
await openSidebarTab(page, 'User Defaults')
await shot.page_('AdminUserDefaults')
})
test('personalization', async ({ page, shot }) => {
await openSidebarTab(page, 'Personalization')
await shot.page_('CategoryManager')
})
test('addons', async ({ page, shot }) => {
await openSidebarTab(page, 'Addons')
await shot.page_('Addons-Overview')
})
test('plugins', async ({ page, shot }) => {
await openSidebarTab(page, 'Plugins')
await shot.page_('AdminPlugins')
})
test('github releases', async ({ page, shot }) => {
await openSidebarTab(page, 'GitHub')
await shot.page_('GithubReleases')
})
test('backup', async ({ page, shot }) => {
await openSidebarTab(page, 'Backup')
await shot.page_('Backup')
})
test('audit log', async ({ page, shot }) => {
await openSidebarTab(page, 'Audit')
await shot.page_('Audit')
})
test('admin panel overview', async ({ page, shot }) => {
await shot.page_('AdminPanel')
})
})
-119
View File
@@ -1,119 +0,0 @@
import { test as base, expect, type Page, type Locator } from '@playwright/test'
import { mkdirSync } from 'node:fs'
import path from 'node:path'
/**
* Shared plumbing for the documentation screenshot run (`npm run shots`).
*
* These are not assertions about behaviour — they drive the app to a known
* state and capture it for the wiki. They live behind their own Playwright
* project (`screenshots`, testMatch /\.shot\.ts/) so a normal `npm run e2e`
* never pays for them.
*
* Output goes to a staging directory, NOT straight into wiki/assets/, so a
* bad run can never clobber good artwork. Promote with `npm run shots:promote`.
*/
// Playwright runs from the client workspace root, matching how
// playwright.config.ts spells `storageState: 'e2e/.tmp/state.json'`.
export const OUT_DIR = path.join(process.cwd(), 'e2e', '.tmp', 'shots')
/** Desktop capture size. 2x scale keeps text crisp; images are squeezed on promote. */
export const VIEWPORT = { width: 1440, height: 900 }
export const test = base.extend<{ shot: Shot }>({
// Overriding `page` (rather than doing this inside the `shot` fixture) is
// deliberate: fixtures initialise lazily, so a route registered in `shot`
// lands AFTER any beforeEach hook has already navigated — too late to
// intercept the config request.
page: async ({ page }, use) => {
await page.setViewportSize(VIEWPORT)
await hideDevOnlyUi(page)
await use(page)
},
shot: async ({ page }, use) => {
mkdirSync(OUT_DIR, { recursive: true })
await use(new Shot(page))
},
})
/**
* The E2E backend runs with NODE_ENV=development, so /auth/app-config reports
* `dev_mode: true` (authService.ts) and the admin sidebar grows a
* "Dev: Notifications" tab that no real deployment ever shows.
*
* Rewriting the response is the surgical fix. Flipping the server to
* NODE_ENV=production would also enable HSTS (globalMiddleware.ts), and an
* HSTS header on localhost would upgrade the run to https and break it.
*/
async function hideDevOnlyUi(page: Page): Promise<void> {
await page.route('**/api/auth/app-config', async route => {
const res = await route.fetch()
const body = await res.json()
await route.fulfill({ response: res, json: { ...body, dev_mode: false } })
})
}
export { expect }
export class Shot {
constructor(private readonly page: Page) {}
/**
* Capture the full viewport. `name` is the target filename in wiki/assets/
* (without extension) so the mapping from screenshot to doc page is literal.
*/
async page_(name: string): Promise<void> {
await this.settle()
await this.page.screenshot({ path: path.join(OUT_DIR, `${name}.png`) })
}
/** Capture one element — preferred for dialogs, panels and cards. */
async element(name: string, target: Locator): Promise<void> {
await this.settle()
await expect(target).toBeVisible()
await target.screenshot({ path: path.join(OUT_DIR, `${name}.png`) })
}
/**
* Quiet the page before capturing: fonts loaded, images decoded, animations
* finished, no pending network. Without this, screenshots catch skeleton
* loaders and half-faded modals, which is exactly how the current wiki
* assets ended up inconsistent.
*/
private async settle(): Promise<void> {
// Bounded: TREK holds a WebSocket open at /ws, so the network never goes
// fully idle and an unbounded wait would burn the whole test timeout.
await this.page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {})
// Await, but return nothing — the resolved FontFaceSet is not serialisable.
await this.page.evaluate(async () => { await document.fonts.ready })
await this.page.evaluate(async () => {
await Promise.all(
Array.from(document.images)
.filter(img => !img.complete)
.map(img => new Promise(res => { img.onload = img.onerror = res })),
)
})
// Let CSS transitions land (modal fade-in, sidebar slide).
await this.page.waitForTimeout(400)
}
}
/**
* Dismiss the first-run system notice. Copied in spirit from e2e/helpers.ts,
* but tolerant: on a seeded DB the notice may already be cleared.
*/
export async function clearNotices(page: Page): Promise<void> {
const next = page.getByRole('button', { name: /next/i })
for (let i = 0; i < 6 && (await next.isVisible().catch(() => false)); i++) {
if (!(await next.isEnabled().catch(() => false))) break
await next.click().catch(() => {})
}
for (const label of ['Dismiss', 'OK']) {
const btn = page.getByRole('button', { name: label, exact: true })
for (let i = 0; i < 4 && (await btn.isVisible().catch(() => false)); i++) {
await btn.click().catch(() => {})
await page.waitForTimeout(300)
}
}
}
-43
View File
@@ -1,43 +0,0 @@
// Boots the TREK backend for the Playwright E2E run against a fresh, isolated
// SQLite database. The DB file is deleted first so every run starts clean, then
// the server's own startup seeds a known admin from ADMIN_EMAIL/ADMIN_PASSWORD.
//
// The server is built once and launched as a SINGLE node process (not the
// watch-mode `npm run dev`, which spawns tsc -w + node --watch grandchildren
// that survive Playwright's teardown and then linger on :3001 with stale DB
// state). A single child is killed cleanly when Playwright tears the run down.
import { rmSync } from 'node:fs'
import { spawn, execSync } from 'node:child_process'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const dbFile = path.join(here, '.tmp', 'e2e.db')
const serverDir = path.join(here, '..', '..', 'server')
for (const f of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`]) {
try { rmSync(f, { force: true }) } catch {}
}
// Build once (no watcher) — the resulting process is a single killable node.
execSync('node scripts/build.mjs', { cwd: serverDir, stdio: 'inherit' })
const env = {
...process.env,
TREK_DB_FILE: dbFile,
ADMIN_EMAIL: 'e2e@trek.local',
ADMIN_PASSWORD: 'E2eTest12345!',
PORT: '3001',
NODE_ENV: 'development',
}
const child = spawn(process.execPath, ['--require', 'tsconfig-paths/register', 'dist/index.js'], {
cwd: serverDir,
env,
stdio: 'inherit',
})
const stop = () => { try { child.kill() } catch {} }
process.on('SIGINT', stop)
process.on('SIGTERM', stop)
process.on('exit', stop)
child.on('exit', code => process.exit(code ?? 0))
-29
View File
@@ -1,29 +0,0 @@
import { test, expect } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Open a trip into the planner: create a trip, open it from the dashboard, and
// confirm the trip planner (TripPlannerPage — the app's largest page) actually
// mounts, proving the day-plan/map shell renders rather than crashing on load.
test('open a trip and land in the planner with a map', async ({ page }) => {
await page.goto('/dashboard')
// The release notice greets a freshly seeded user and its backdrop eats the click below.
await dismissSystemNotices(page)
// Create a trip to open.
await page.locator('.add-trip-card').click()
const modal = page.locator('.trek-modal-backdrop')
await expect(modal).toBeVisible()
// Target Title by placeholder: the cover-image search inputs sit above it, so
// input[type=text].first() is the photo search box, not the field we want.
const title = `E2E Planner ${Date.now()}`
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
await modal.getByRole('button', { name: 'Create New Trip' }).click()
// Open it from the dashboard.
await page.getByText(title).first().click()
await expect(page).toHaveURL(/\/trips\/\d+/)
// The planner shows a Leaflet map once mounted (past the splash screen).
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
})
-78
View File
@@ -1,78 +0,0 @@
import js from '@eslint/js';
import gitignore from 'eslint-config-flat-gitignore';
import eslintConfigPrettier from 'eslint-config-prettier';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
// Minimal stub so the existing `// eslint-disable-next-line react/no-danger`
// directive in src/i18n/TransHtml.tsx resolves without pulling in the full
// eslint-plugin-react (not a dependency here). The rule is a no-op.
const reactStub = {
rules: {
'no-danger': {
meta: { schema: [] },
create() {
return {};
},
},
},
};
export default tseslint.config(
gitignore({ strict: false }),
{
ignores: [
'node_modules',
'dist',
'coverage',
'public',
'test-results',
'playwright-report',
'e2e/**',
'scripts/**',
'**/*.config.js',
'**/*.config.ts',
'**/*.config.mjs',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
eslintConfigPrettier,
{
files: ['src/**/*.{ts,tsx}', 'tests/**/*.{ts,tsx}'],
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
react: reactStub,
},
rules: {
'react/no-danger': 'off',
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
// --- Severities tuned to keep CI green on a codebase that was never linted ---
// (each rule below has pre-existing violations; surfaced as warnings, not blockers)
// rules-of-hooks has one conditional-hook violation in PlaceInspector.tsx -> warn (not error).
'react-hooks/rules-of-hooks': 'warn',
'react-hooks/exhaustive-deps': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
],
'@typescript-eslint/no-unused-expressions': 'warn',
'@typescript-eslint/no-unsafe-function-type': 'warn',
'@typescript-eslint/no-this-alias': 'warn',
'@typescript-eslint/no-non-null-asserted-optional-chain': 'warn',
// js.recommended rules with pre-existing hits.
'no-empty': 'warn',
'no-useless-escape': 'warn',
'no-useless-assignment': 'warn',
'preserve-caught-error': 'warn',
},
},
);
+6 -5
View File
@@ -5,10 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>TREK</title>
<!-- Pre-paint appearance (FOUC fix). External classic script so it runs
before first paint AND complies with the prod CSP (script-src 'self'). -->
<script src="/theme-boot.js"></script>
<!-- PWA / iOS -->
<meta name="theme-color" content="#09090b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
@@ -17,12 +13,17 @@
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
</head>
<body>
<div id="root"></div>
+11079
View File
File diff suppressed because it is too large Load Diff
+16 -52
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "3.4.1",
"name": "trek-client",
"version": "3.0.18",
"private": true,
"type": "module",
"scripts": {
@@ -8,46 +8,25 @@
"prebuild": "node scripts/generate-icons.mjs",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:unit": "vitest run tests/unit",
"test:integration": "vitest run tests/integration src/**/*.test.{ts,tsx}",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint .",
"lint:check": "eslint .",
"lint:pages": "node scripts/check-page-pattern.mjs",
"theme:lint": "node scripts/theme-lint.mjs",
"theme:lint:strict": "node scripts/theme-lint.mjs --strict",
"e2e": "playwright test",
"shots": "playwright test --project=screenshots",
"shots:promote": "node e2e/screenshots/promote.mjs",
"e2e:report": "playwright show-report",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@fontsource/geist-sans": "^5.2.5",
"@fontsource/poppins": "^5.2.7",
"@react-pdf/renderer": "^4.5.1",
"@simplewebauthn/browser": "^13.1.2",
"@trek/shared": "*",
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
"dexie": "^4.4.2",
"drag-drop-touch": "^1.3.1",
"heic-to": "^1.4.2",
"iso-3166-2": "^1.0.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
"maplibre-gl": "^5.24.0",
"marked": "^18.0.0",
"plyr": "^3.8.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.4.1",
"react-leaflet": "^5.0.0",
"react-leaflet-cluster": "^4.1.3",
"react-leaflet": "^4.2.1",
"react-leaflet-cluster": "^2.1.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.2",
"react-window": "^2.2.7",
@@ -55,43 +34,28 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
"tz-lookup": "^6.1.25",
"zod": "^4.3.6",
"zustand": "^4.5.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/leaflet": "^1.9.8",
"@types/node": "^25.9.3",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@types/react": "^18.2.61",
"@types/react-dom": "^18.2.19",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.9",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^3.2.4",
"autoprefixer": "^10.4.18",
"eslint": "^10.2.1",
"eslint-config-flat-gitignore": "^2.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"fake-indexeddb": "^6.2.5",
"jsdom": "^29.0.1",
"msw": "^2.13.0",
"postcss": "^8.4.35",
"prettier": "^3.8.3",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"sharp": "^0.35.0",
"sharp": "^0.33.0",
"tailwindcss": "^3.4.1",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "8.1.0",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.9"
"vite": "^5.1.4",
"vite-plugin-pwa": "^0.21.0",
"vitest": "^3.2.4"
}
}
-79
View File
@@ -1,79 +0,0 @@
import { defineConfig, devices } from '@playwright/test'
/**
* E2E harness for TREK's critical user flows (FE7).
*
* Two web servers are orchestrated: the Express/Nest backend on :3001 against an
* isolated throwaway SQLite DB (e2e/server-launch.mjs sets TREK_DB_FILE + seeds a
* known admin), and the Vite dev server on :5173 which proxies /api, /uploads,
* /ws to the backend. Tests run serially against one worker so they share the
* single seeded database deterministically.
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
workers: 1,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
timeout: 45_000,
expect: { timeout: 15_000 },
reporter: [['list']],
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
// Unauthenticated flows (login, register, public share) — no stored session.
{ name: 'public', testMatch: /\.public\.spec\.ts/, use: { ...devices['Desktop Chrome'] } },
// One-time login that persists a session for the authenticated flows.
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'app',
testMatch: /\.spec\.ts/,
testIgnore: /(\.public\.spec\.ts|auth\.setup\.ts)/,
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' },
dependencies: ['setup'],
},
// Documentation screenshots (`npm run shots`). Excluded from the normal e2e
// run by its own testMatch — these capture artwork for wiki/assets/, they
// assert nothing. 2x scale keeps text crisp at the sizes the wiki renders.
// Populates the demo trip the screenshots are taken of. Separate project so
// it runs exactly once, between auth and capture.
{
name: 'seed',
testMatch: /seed\.setup\.ts/,
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' },
dependencies: ['setup'],
},
{
name: 'screenshots',
testMatch: /\.shot\.ts/,
use: {
...devices['Desktop Chrome'],
storageState: 'e2e/.tmp/state.json',
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
},
dependencies: ['seed'],
},
],
webServer: [
{
// Always start our own backend (never reuse) so the isolated test DB is
// reset + reseeded on every run, regardless of any stray dev server.
command: 'node e2e/server-launch.mjs',
port: 3001,
reuseExistingServer: false,
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
},
{
command: 'npm run dev',
port: 5173,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
],
})
-58
View File
@@ -1,58 +0,0 @@
/*
* Pre-paint appearance boot — kills the flash of default/wrong theme (FOUC).
*
* Loaded as an external, render-blocking CLASSIC script in <head> (NOT a module)
* so it runs before first paint AND complies with the production CSP
* (script-src 'self'; inline scripts are blocked). It reads the compact snapshot
* written by client/src/theme/applyAppearance.ts and applies it verbatim. Keep
* this in sync with that module's snapshot shape + apply logic.
*
* It must never throw — any failure silently falls back to the default look.
*/
(function () {
try {
var raw = localStorage.getItem('trek_appearance');
if (!raw) return;
var s = JSON.parse(raw);
if (!s || s.v !== 1) return;
var root = document.documentElement;
var path = location.pathname;
var isShared = path.indexOf('/shared/') === 0 || path.indexOf('/public/') === 0;
var dark;
if (isShared) dark = false;
else if (s.darkMode === 'auto') dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
else dark = s.darkMode === true || s.darkMode === 'dark';
root.classList.toggle('dark', dark);
var scheme = isShared ? 'default' : s.scheme;
if (scheme && scheme !== 'default') root.setAttribute('data-scheme', scheme);
if (!isShared && s.noTransparency) root.setAttribute('data-no-transparency', '');
if (s.density === 'compact') root.setAttribute('data-density', 'compact');
if (s.reduceMotion) root.setAttribute('data-reduce-motion', '');
if (!isShared && scheme === 'custom' && s.accent) {
root.style.setProperty('--accent-custom-light', s.accent.light);
root.style.setProperty('--accent-custom-dark', s.accent.dark);
if (s.accentText) {
root.style.setProperty('--accent-custom-text-light', s.accentText.light);
root.style.setProperty('--accent-custom-text-dark', s.accentText.dark);
}
}
var ts = s.typeScale || {};
var fs = typeof s.fontScale === 'number' ? s.fontScale : 1;
setScale('--fs-scale-title', fs * (ts.title || 1));
setScale('--fs-scale-subtitle', fs * (ts.subtitle || 1));
setScale('--fs-scale-body', fs * (ts.body || 1));
setScale('--fs-scale-caption', fs * (ts.caption || 1));
if (fs !== 1) root.style.fontSize = fs * 100 + '%';
function setScale(name, v) {
if (typeof v === 'number' && v !== 1) root.style.setProperty(name, String(v));
}
} catch (e) {
/* never block boot */
}
})();
-44
View File
@@ -1,44 +0,0 @@
// Guards the "Page = wiring container + data hook" convention (see
// src/pages/PATTERN.md). A *Page.tsx default-export component should wire a
// co-located use<Page>() hook into JSX — it must not own state/effects itself.
//
// We scan only the default-export component body (from `export default function`
// up to the next top-level `function` declaration or EOF), so presentational
// sub-components and helper hooks living in the same file are not flagged.
// Context hooks like useTranslation/useParams are fine; the smell is stateful
// logic — useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef.
import { readdirSync, readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const pagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'pages')
const BANNED = ['useState', 'useReducer', 'useEffect', 'useLayoutEffect', 'useMemo', 'useCallback', 'useRef']
const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*\\(`)
const violations = []
for (const file of readdirSync(pagesDir)) {
if (!file.endsWith('Page.tsx') || file.endsWith('.test.tsx')) continue
const src = readFileSync(join(pagesDir, file), 'utf8')
const lines = src.split('\n')
const start = lines.findIndex(l => /export default function/.test(l))
if (start === -1) continue
// The page body ends at the next top-level declaration (a `function` at
// column 0) — everything after that is a sub-component or helper.
let end = lines.length
for (let i = start + 1; i < lines.length; i++) {
if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break }
}
for (let i = start; i < end; i++) {
if (bannedRe.test(lines[i])) {
violations.push(`${file}:${i + 1} ${lines[i].trim()}`)
}
}
}
if (violations.length > 0) {
console.error('Page-pattern violations — move this state/effect logic into the page\'s use<Page>() hook:\n')
for (const v of violations) console.error(' ' + v)
console.error(`\n${violations.length} violation(s). See src/pages/PATTERN.md.`)
process.exit(1)
}
console.log('Page pattern OK — no state/effect logic in page containers.')
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env node
/*
* theme:lint — guards the appearance token system.
*
* Flags styling that bypasses the design tokens and therefore won't follow a
* user's chosen scheme / transparency / text-size:
* - inline color literals (color: '#111', background: 'rgba(...)', boxShadow: '...rgba...')
* - inline numeric fontSize (fontSize: 13)
* - arbitrary-value Tailwind color classes (bg-[#..], text-[rgba(..)])
*
* ALLOWED (never flagged): var(--token) inline styles, bg-[var(--..)] classes,
* and genuinely dynamic values (data-driven colors, computed sizes/positions).
*
* Mirrors the i18n:parity gate. Default mode reports a baseline and exits 0;
* `--strict` exits non-zero when any violations remain (for once the backlog is
* burned down, or wired to changed files only). Add `theme-lint-disable` in a
* line comment to suppress an intentional exception (map/PDF/brand colors).
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
let SRC = new URL('../src', import.meta.url).pathname;
if (process.platform === 'win32' && SRC.startsWith('/')) SRC = SRC.slice(1);
// Surfaces where CSS variables genuinely cannot reach (injected map HTML, WebGL
// paint, standalone PDF documents) — colors there must stay literal.
const EXEMPT = [
/Mapbox/i, /placePopup/i, /marker/i, /popup/i, /TripPDF/, /JourneyBookPDF/,
/MapViewGL/, /MapView\./, /JourneyMapGL/, /reservationsMapbox/, /useAtlas/,
/ReservationOverlay/, /\.test\./, /\.spec\./,
];
const ARB_CLASS = /\b(?:bg|text|border|ring|fill|stroke|from|via|to|shadow|outline|decoration|divide|caret)-\[\s*(?:#|rgba?\(|hsla?\(|oklch\()/;
const INLINE_COLOR = /(?:color|background|backgroundColor|borderColor|border|borderTop|borderBottom|borderLeft|borderRight|boxShadow|fill|stroke|outline|textDecorationColor)\s*:\s*['"`]?\s*(?:#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(|oklch\()/;
const INLINE_FONTSIZE = /fontSize\s*:\s*['"`]?\d/;
function walk(dir, files = []) {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) walk(p, files);
else if (/\.(ts|tsx)$/.test(name)) files.push(p);
}
return files;
}
const strict = process.argv.includes('--strict');
const offenders = [];
let total = 0;
for (const f of walk(SRC)) {
if (EXEMPT.some((re) => re.test(f))) continue;
let count = 0;
for (const line of readFileSync(f, 'utf8').split('\n')) {
if (line.includes('theme-lint-disable')) continue;
if (ARB_CLASS.test(line) || INLINE_COLOR.test(line) || INLINE_FONTSIZE.test(line)) count++;
}
if (count) {
offenders.push([relative(SRC, f).replace(/\\/g, '/'), count]);
total += count;
}
}
offenders.sort((a, b) => b[1] - a[1]);
console.log(`theme:lint — ${total} hardcoded-style hits across ${offenders.length} files (map/PDF excluded).`);
for (const [f, c] of offenders.slice(0, 20)) console.log(` ${String(c).padStart(4)} ${f}`);
if (offenders.length > 20) console.log(` … and ${offenders.length - 20} more files.`);
console.log('\nNew/changed code must use tokens (bg-surface / text-content / bg-accent / var(--..)) and the');
console.log('text-title/subtitle/body/caption tiers — never inline #hex, never bg-[#..]. See src/theme/README.md.');
if (strict && total > 0) {
console.error(`\n✖ theme:lint:strict — ${total} violations remain.`);
process.exit(1);
}
+28 -84
View File
@@ -2,10 +2,7 @@ import React, { useEffect, ReactNode } from 'react'
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import { applyAppearance } from './theme/applyAppearance'
import { useAddonStore } from './store/addonStore'
import { usePluginStore } from './store/pluginStore'
import PluginPage from './pages/PluginPage'
import LoginPage from './pages/LoginPage'
import ForgotPasswordPage from './pages/ForgotPasswordPage'
import ResetPasswordPage from './pages/ResetPasswordPage'
@@ -15,22 +12,15 @@ import FilesPage from './pages/FilesPage'
import AdminPage from './pages/AdminPage'
import SettingsPage from './pages/SettingsPage'
import VacayPage from './pages/VacayPage'
import HelpPage from './pages/HelpPage'
import AtlasPage from './pages/AtlasPage'
import JourneyPage from './pages/JourneyPage'
import JourneyDetailPage from './pages/JourneyDetailPage'
import CollectionsPage from './pages/CollectionsPage'
import JourneyPublicPage from './pages/JourneyPublicPage'
import SharedTripPage from './pages/SharedTripPage'
import JoinTripPage from './pages/JoinTripPage'
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
import { ToastContainer } from './components/shared/Toast'
import SaveToCollectionModal from './components/Collections/SaveToCollectionModal'
import MSaveToCollectionSheet from './components/Collections/MSaveToCollectionSheet'
import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget'
import MobileShell from './mobile/MobileShell'
import { useIsPhone } from './mobile/useIsPhone'
import BottomNav from './components/Layout/BottomNav'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi } from './api/client'
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
@@ -55,7 +45,6 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
const addonStore = useAddonStore()
const { t } = useTranslation()
const location = useLocation()
const isPhone = useIsPhone()
if (isLoading) {
return (
@@ -90,11 +79,12 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
return <Navigate to="/dashboard" replace />
}
// Below the md breakpoint the new mobile shell owns chrome (tokens, dock,
// sheets, toasts); from 768px up the legacy wrapper stays untouched. The
// shell branches internally so pages keep their state when the viewport
// crosses the breakpoint.
return <MobileShell isPhone={isPhone}>{children}</MobileShell>
return (
<div className="flex flex-col h-screen md:block md:h-auto">
<div className="flex-1 overflow-y-auto md:overflow-visible">{children}</div>
<BottomNav />
</div>
)
}
function RootRedirect() {
@@ -115,7 +105,6 @@ export default function App() {
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
const { loadPlugins } = usePluginStore()
useEffect(() => {
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
@@ -130,7 +119,7 @@ export default function App() {
}
}
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
setDemoMode(!!config?.demo_mode)
if (config?.demo_mode) setDemoMode(true)
if (config?.dev_mode) setDevMode(true)
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
if (config?.version) setAppVersion(config.version)
@@ -173,7 +162,6 @@ export default function App() {
if (isAuthenticated) {
loadSettings()
loadAddons()
loadPlugins()
}
}, [isAuthenticated])
@@ -186,23 +174,31 @@ export default function App() {
const isSharedPage = location.pathname.startsWith('/shared/')
useEffect(() => {
const run = () =>
applyAppearance({
darkMode: settings.dark_mode,
appearance: settings.appearance,
isSharedPage,
})
run()
// Re-resolve on OS theme change while in auto mode.
if (!isSharedPage && settings.dark_mode === 'auto') {
// Shared page always forces light mode
if (isSharedPage) {
document.documentElement.classList.remove('dark')
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', '#ffffff')
return
}
const mode = settings.dark_mode
const applyDark = (isDark: boolean) => {
document.documentElement.classList.toggle('dark', isDark)
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', isDark ? '#09090b' : '#ffffff')
}
if (mode === 'auto') {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => run()
applyDark(mq.matches)
const handler = (e: MediaQueryListEvent) => applyDark(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}
}, [settings.dark_mode, settings.appearance, isSharedPage])
applyDark(mode === true || mode === 'dark')
}, [settings.dark_mode, isSharedPage])
const isPhone = useIsPhone()
const isAuthPage = location.pathname.startsWith('/login')
|| location.pathname.startsWith('/register')
|| location.pathname.startsWith('/forgot-password')
@@ -212,8 +208,6 @@ export default function App() {
<TranslationProvider>
{!isAuthPage && <SystemNoticeHost />}
<ToastContainer />
{!isAuthPage && <BackgroundTasksWidget />}
{!isAuthPage && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
<OfflineBanner />
<Routes>
<Route path="/" element={<RootRedirect />} />
@@ -233,32 +227,6 @@ export default function App() {
</ProtectedRoute>
}
/>
{/* Trip invite link (#1143) — behind ProtectedRoute so an anonymous
visitor is redirected to /login (never registration) and returns here. */}
<Route
path="/join/:token"
element={
<ProtectedRoute>
<JoinTripPage />
</ProtectedRoute>
}
/>
<Route
path="/help"
element={
<ProtectedRoute>
<HelpPage />
</ProtectedRoute>
}
/>
<Route
path="/help/:slug"
element={
<ProtectedRoute>
<HelpPage />
</ProtectedRoute>
}
/>
<Route
path="/trips/:id"
element={
@@ -291,14 +259,6 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/plugins/:pluginId"
element={
<ProtectedRoute>
<PluginPage />
</ProtectedRoute>
}
/>
<Route
path="/vacay"
element={
@@ -331,22 +291,6 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/collections"
element={
<ProtectedRoute addonId="collections">
<CollectionsPage />
</ProtectedRoute>
}
/>
<Route
path="/collections/:id"
element={
<ProtectedRoute addonId="collections">
<CollectionsPage />
</ProtectedRoute>
}
/>
<Route
path="/notifications"
element={
-490
View File
@@ -1,490 +0,0 @@
// FE-APIWIRE-001 to FE-APIWIRE-036
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { AxiosError, type AxiosAdapter, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import { http, HttpResponse } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import { weatherResultSchema } from '@trek/shared'
// client.ts probes the health endpoint to tell an edge-proxy auth wall apart
// from a plain offline boot — the probe result decides whether it tears down
// the service worker, so the tests drive it directly.
const { probeNow } = vi.hoisted(() => ({
probeNow: vi.fn(async (): Promise<'online' | 'offline' | 'proxy-wall'> => 'offline'),
}))
vi.mock('../sync/connectivity', () => ({ probeNow }))
const { apiClient, adminApi, mapsApi, pluginsApi, parseInDev } = await import('./client')
interface FakeLocation {
href: string
origin: string
pathname: string
search: string
hash: string
reload: () => void
}
let reload: ReturnType<typeof vi.fn<() => void>>
function setLocation(pathname: string, search = '', hash = ''): FakeLocation {
reload = vi.fn<() => void>()
const loc: FakeLocation = {
href: `http://localhost:3000${pathname}${search}${hash}`,
origin: 'http://localhost:3000',
pathname,
search,
hash,
reload,
}
Object.defineProperty(window, 'location', { writable: true, configurable: true, value: loc })
return loc
}
const realLocation = window.location
/** Records the outgoing config and answers 200 without touching the network. */
function okAdapter(sink: InternalAxiosRequestConfig[]): AxiosAdapter {
return (config) => {
sink.push(config)
return Promise.resolve({
data: { ok: true }, status: 200, statusText: 'OK', headers: {}, config,
} as AxiosResponse)
}
}
/** Rejects the way a CORS/offline failure does: an error with no `response`. */
const networkErrorAdapter: AxiosAdapter = (config) =>
Promise.reject(new AxiosError('Network Error', AxiosError.ERR_NETWORK, config))
async function captureError(run: () => Promise<unknown>): Promise<AxiosError> {
const err = await run().then(() => null, (e: unknown) => e as AxiosError)
expect(err, 'expected the request to reject').not.toBeNull()
return err as AxiosError
}
beforeEach(() => {
probeNow.mockResolvedValue('offline')
setLocation('/dashboard')
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
Object.defineProperty(window, 'location', { writable: true, configurable: true, value: realLocation })
delete (navigator as { serviceWorker?: unknown }).serviceWorker
})
describe('client > request interceptor', () => {
it('FE-APIWIRE-001: mutating requests get an idempotency key, reads do not', async () => {
const sink: InternalAxiosRequestConfig[] = []
const adapter = okAdapter(sink)
await apiClient.get('/probe', { adapter })
await apiClient.post('/probe', {}, { adapter })
await apiClient.put('/probe', {}, { adapter })
await apiClient.patch('/probe', {}, { adapter })
await apiClient.delete('/probe', { adapter })
const keys = sink.map(c => c.headers['X-Idempotency-Key'])
expect(keys[0]).toBeUndefined()
for (const key of keys.slice(1)) expect(typeof key).toBe('string')
})
it('FE-APIWIRE-002: each write gets its own key so retries can be deduplicated', async () => {
const sink: InternalAxiosRequestConfig[] = []
const adapter = okAdapter(sink)
await apiClient.post('/probe', {}, { adapter })
await apiClient.post('/probe', {}, { adapter })
expect(sink[0].headers['X-Idempotency-Key']).not.toBe(sink[1].headers['X-Idempotency-Key'])
})
it('FE-APIWIRE-003: a pre-generated key from the mutation queue is left alone', async () => {
const sink: InternalAxiosRequestConfig[] = []
await apiClient.post('/probe', {}, {
adapter: okAdapter(sink),
headers: { 'X-Idempotency-Key': 'queued-key' },
})
expect(sink[0].headers['X-Idempotency-Key']).toBe('queued-key')
})
it('FE-APIWIRE-004: falls back to a random token when crypto.randomUUID is missing', async () => {
const realCrypto = globalThis.crypto
vi.stubGlobal('crypto', {
getRandomValues: realCrypto.getRandomValues.bind(realCrypto),
} as unknown as Crypto)
const sink: InternalAxiosRequestConfig[] = []
await apiClient.post('/probe', {}, { adapter: okAdapter(sink) })
const key = String(sink[0].headers['X-Idempotency-Key'])
expect(key).toMatch(/^[a-z0-9]+$/)
expect(key).not.toMatch(/-/)
})
it('FE-APIWIRE-005: the socket id header is omitted while no socket is connected', async () => {
const sink: InternalAxiosRequestConfig[] = []
await apiClient.get('/probe', { adapter: okAdapter(sink) })
expect(sink[0].headers['X-Socket-Id']).toBeUndefined()
})
it('FE-APIWIRE-034: a rejection from an earlier request interceptor is passed on untouched', async () => {
const boom = new Error('interceptor refused the request')
const id = apiClient.interceptors.request.use(() => Promise.reject(boom))
const sink: InternalAxiosRequestConfig[] = []
try {
await expect(apiClient.post('/probe', {}, { adapter: okAdapter(sink) })).rejects.toBe(boom)
} finally {
apiClient.interceptors.request.eject(id)
}
expect(sink).toHaveLength(0)
})
})
describe('client > rate-limit translation', () => {
beforeEach(() => {
server.use(http.get('/api/limited', () => HttpResponse.json({ error: 'Too Many Requests' }, { status: 429 })))
})
it('FE-APIWIRE-006: a 429 is rewritten in the stored app language', async () => {
localStorage.setItem('app_language', 'de')
const err = await captureError(() => apiClient.get('/limited'))
expect(err.message).toBe('Zu viele Versuche. Bitte versuchen Sie es später erneut.')
expect((err.response?.data as { error: string }).error)
.toBe('Zu viele Versuche. Bitte versuchen Sie es später erneut.')
})
it('FE-APIWIRE-007: an unsupported language falls back to English', async () => {
localStorage.setItem('app_language', 'kl')
const err = await captureError(() => apiClient.get('/limited'))
expect(err.message).toBe('Too many attempts. Please try again later.')
})
it('FE-APIWIRE-008: no stored language falls back to English', async () => {
const err = await captureError(() => apiClient.get('/limited'))
expect(err.message).toBe('Too many attempts. Please try again later.')
})
it('FE-APIWIRE-009: a blocked localStorage still yields the English message', async () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('storage disabled')
})
const err = await captureError(() => apiClient.get('/limited'))
expect(err.message).toBe('Too many attempts. Please try again later.')
})
it('FE-APIWIRE-010: a non-object 429 body is replaced with the translated error object', async () => {
server.use(http.get('/api/limited', () => new HttpResponse('slow down', { status: 429 })))
const err = await captureError(() => apiClient.get('/limited'))
expect(err.response?.data).toEqual({ error: 'Too many attempts. Please try again later.' })
})
it('FE-APIWIRE-035: an array 429 body is replaced, not grafted onto', async () => {
server.use(http.get('/api/limited', () => HttpResponse.json([{ field: 'email' }], { status: 429 })))
const err = await captureError(() => apiClient.get('/limited'))
expect(err.response?.data).toEqual({ error: 'Too many attempts. Please try again later.' })
})
it('FE-APIWIRE-036: Catalan, Greek and Vietnamese have their own 429 message', async () => {
for (const lang of ['ca', 'gr', 'vi']) {
localStorage.setItem('app_language', lang)
const err = await captureError(() => apiClient.get('/limited'))
expect(err.message).not.toBe('Too many attempts. Please try again later.')
}
})
})
describe('client > proxy auth challenges', () => {
function installServiceWorker(unregister: () => Promise<boolean>) {
const getRegistration = vi.fn(async () => ({ unregister }))
Object.defineProperty(navigator, 'serviceWorker', {
writable: true, configurable: true, value: { getRegistration },
})
return getRegistration
}
it('FE-APIWIRE-011: an HTML 401 unregisters the service worker and reloads', async () => {
const unregister = vi.fn(async () => true)
installServiceWorker(unregister)
server.use(http.get('/api/auth/me', () =>
new HttpResponse('<html>login</html>', { status: 401, headers: { 'Content-Type': 'text/html' } })))
await captureError(() => apiClient.get('/auth/me'))
expect(unregister).toHaveBeenCalled()
expect(reload).toHaveBeenCalledTimes(1)
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBe('1')
})
it('FE-APIWIRE-012: the reauth reload only fires once per session', async () => {
installServiceWorker(vi.fn(async () => true))
sessionStorage.setItem('proxy_reauth_attempted', '1')
server.use(http.get('/api/auth/me', () =>
new HttpResponse('<html>login</html>', { status: 401, headers: { 'Content-Type': 'text/html' } })))
await captureError(() => apiClient.get('/auth/me'))
expect(reload).not.toHaveBeenCalled()
})
it('FE-APIWIRE-013: an HTML 401 on a public path never reloads', async () => {
setLocation('/login')
server.use(http.get('/api/auth/me', () =>
new HttpResponse('<html>login</html>', { status: 401, headers: { 'Content-Type': 'text/html' } })))
await captureError(() => apiClient.get('/auth/me'))
expect(reload).not.toHaveBeenCalled()
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull()
})
it('FE-APIWIRE-014: a response-less failure that probes proxy-wall reloads', async () => {
probeNow.mockResolvedValue('proxy-wall')
installServiceWorker(vi.fn(async () => true))
await captureError(() => apiClient.get('/auth/me', { adapter: networkErrorAdapter }))
expect(probeNow).toHaveBeenCalled()
expect(reload).toHaveBeenCalledTimes(1)
})
it('FE-APIWIRE-015: a response-less failure that probes offline keeps the SW (#1346)', async () => {
probeNow.mockResolvedValue('offline')
const getRegistration = installServiceWorker(vi.fn(async () => true))
await captureError(() => apiClient.get('/auth/me', { adapter: networkErrorAdapter }))
expect(getRegistration).not.toHaveBeenCalled()
expect(reload).not.toHaveBeenCalled()
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull()
})
it('FE-APIWIRE-016: a failing unregister still reloads into the proxy challenge', async () => {
probeNow.mockResolvedValue('proxy-wall')
Object.defineProperty(navigator, 'serviceWorker', {
writable: true, configurable: true,
value: { getRegistration: vi.fn(async () => { throw new Error('SW gone') }) },
})
await captureError(() => apiClient.get('/auth/me', { adapter: networkErrorAdapter }))
expect(reload).toHaveBeenCalledTimes(1)
})
it('FE-APIWIRE-017: a proxy-wall probe on a shared page does not reload', async () => {
setLocation('/shared/tok123')
probeNow.mockResolvedValue('proxy-wall')
await captureError(() => apiClient.get('/auth/me', { adapter: networkErrorAdapter }))
expect(reload).not.toHaveBeenCalled()
})
it('FE-APIWIRE-035: a 401 without a content-type is not mistaken for a proxy login page', async () => {
installServiceWorker(vi.fn(async () => true))
server.use(http.get('/api/auth/me', () => new HttpResponse(null, { status: 401 })))
await captureError(() => apiClient.get('/auth/me'))
expect(reload).not.toHaveBeenCalled()
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull()
})
it('FE-APIWIRE-018: a successful response clears the reauth marker', async () => {
sessionStorage.setItem('proxy_reauth_attempted', '1')
server.use(http.get('/api/auth/me', () => HttpResponse.json({ ok: true })))
await apiClient.get('/auth/me')
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull()
})
})
describe('client > redirect handling', () => {
it('FE-APIWIRE-019: a JSON AUTH_REQUIRED 401 redirects with the full current path', async () => {
const loc = setLocation('/trips/7', '?tab=map', '#day-2')
server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 })))
await captureError(() => apiClient.get('/auth/me'))
expect(loc.href).toBe('/login?redirect=' + encodeURIComponent('/trips/7?tab=map#day-2'))
})
it('FE-APIWIRE-020: an MFA_REQUIRED 403 sends the user to the settings page', async () => {
const loc = setLocation('/dashboard')
server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'MFA_REQUIRED' }, { status: 403 })))
await captureError(() => apiClient.get('/auth/me'))
expect(loc.href).toBe('/settings?mfa=required')
})
})
describe('client > dev-only contract drift checks', () => {
it('FE-APIWIRE-021: parseInDev passes a matching payload straight through', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const payload = { temp: 21, main: 'Clear', description: 'clear sky', type: 'sun' }
expect(parseInDev(weatherResultSchema, payload, 'weather.get')).toBe(payload)
expect(warn).not.toHaveBeenCalled()
})
it('FE-APIWIRE-022: parseInDev warns but still returns a drifting payload', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const payload = { temp: 'warm', main: 'Clear', description: 'clear sky', type: 'sun' }
expect(parseInDev(weatherResultSchema, payload, 'weather.get')).toBe(payload)
expect(warn).toHaveBeenCalledWith(
'[api] weather.get: response did not match the @trek/shared schema',
expect.anything(),
)
})
it('FE-APIWIRE-023: a drifting maps response is reported under its own label', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
server.use(http.post('/api/maps/search', () => HttpResponse.json({ nonsense: true })))
await expect(mapsApi.search('Rome')).resolves.toEqual({ nonsense: true })
expect(warn).toHaveBeenCalledWith(
'[api] maps.search: response did not match the @trek/shared schema',
expect.anything(),
)
})
})
describe('client > pluginsApi.invoke namespace guard', () => {
it('FE-APIWIRE-024: a relative sub-path stays inside the plugin namespace', async () => {
let seen = ''
server.use(http.get('/api/plugins/koffi/ping', ({ request }) => {
seen = new URL(request.url).pathname
return HttpResponse.json({ pong: true })
}))
await expect(pluginsApi.invoke('koffi', '/ping')).resolves.toEqual({ pong: true })
expect(seen).toBe('/api/plugins/koffi/ping')
})
it('FE-APIWIRE-025: method, body and query string survive the rewrite', async () => {
let received: unknown
let query = ''
server.use(http.post('/api/plugins/koffi/sync', async ({ request }) => {
received = await request.json()
query = new URL(request.url).search
return HttpResponse.json({ ok: true })
}))
await pluginsApi.invoke('koffi', 'sync?full=1', { method: 'POST', body: { since: 5 } })
expect(received).toEqual({ since: 5 })
expect(query).toBe('?full=1')
})
it('FE-APIWIRE-026: traversal out of the plugin prefix is refused', async () => {
await expect(pluginsApi.invoke('koffi', '/../../auth/me'))
.rejects.toThrow('plugin route escapes its namespace')
})
it('FE-APIWIRE-027: an absolute off-origin target is refused', async () => {
await expect(pluginsApi.invoke('koffi', 'https://evil.test/steal'))
.rejects.toThrow('plugin route escapes its namespace')
})
it('FE-APIWIRE-028: an unparseable sub-path is refused before any request', async () => {
await expect(pluginsApi.invoke('koffi', 'http://')).rejects.toThrow('invalid plugin route')
})
})
describe('client > adminApi.llmLocalPull', () => {
function streamingResponse(chunks: string[]): Response {
let i = 0
const encoder = new TextEncoder()
return {
ok: true,
status: 200,
body: {
getReader: () => ({
read: async () => (i < chunks.length
? { done: false, value: encoder.encode(chunks[i++]) }
: { done: true, value: undefined }),
cancel: async () => {},
}),
},
} as unknown as Response
}
it('FE-APIWIRE-029: NDJSON progress lines are reported even when split across chunks', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamingResponse([
'{"status":"pulling","total":100,"completed":10}\n{"status":"pul',
'ling","total":100,"completed":90}\n{"status":"success"}\n',
]))
const onProgress = vi.fn((_p: { status?: string }) => {})
await adminApi.llmLocalPull('http://ollama:11434', 'qwen3:8b', onProgress)
expect(onProgress.mock.calls.map(c => c[0])).toEqual([
{ status: 'pulling', total: 100, completed: 10 },
{ status: 'pulling', total: 100, completed: 90 },
{ status: 'success' },
])
})
it('FE-APIWIRE-030: blank and half-written lines are skipped instead of throwing', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamingResponse([
'\n \n{"status":"a"}\nnot-json\n{"status":"b"}\n',
]))
const onProgress = vi.fn((_p: { status?: string }) => {})
await adminApi.llmLocalPull('http://ollama:11434', 'qwen3:8b', onProgress)
expect(onProgress.mock.calls.map(c => c[0])).toEqual([{ status: 'a' }, { status: 'b' }])
})
it('FE-APIWIRE-031: a JSON error body becomes the thrown message', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
ok: false, status: 502, body: null,
json: async () => ({ error: 'ollama unreachable' }),
} as unknown as Response)
await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', vi.fn()))
.rejects.toThrow('ollama unreachable')
})
it('FE-APIWIRE-032: a non-JSON error body falls back to the status code', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
ok: false, status: 500, body: null,
json: async () => { throw new SyntaxError('not json') },
} as unknown as Response)
await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', vi.fn()))
.rejects.toThrow('Pull failed (500)')
})
it('FE-APIWIRE-036: a throw from onProgress aborts the pull', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamingResponse([
'{"status":"pulling manifest"}\n{"error":"manifest not found"}\n{"status":"success"}\n',
]))
const onProgress = vi.fn((p: { error?: string }) => {
if (p.error) throw new Error(p.error)
})
await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', onProgress))
.rejects.toThrow('manifest not found')
expect(onProgress).toHaveBeenCalledTimes(2)
})
it('FE-APIWIRE-033: a 200 without a readable body reports the missing stream', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
ok: true, status: 200, body: null,
json: async () => ({}),
} as unknown as Response)
await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', vi.fn()))
.rejects.toThrow('Pull returned no progress stream')
})
})
-843
View File
@@ -1,843 +0,0 @@
// FE-APISURF-001 to FE-APISURF-052
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { AxiosResponse } from 'axios'
import { http, HttpResponse } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import {
apiClient,
authApi, oauthApi, tripsApi, daysApi, placesApi, assignmentsApi, packingApi, todoApi,
tagsApi, categoriesApi, adminApi, addonsApi, pluginsApi, airtrailApi, journeyApi,
mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi,
configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi,
shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi,
} from './client'
interface Recorded { method: string; url: string; body: unknown }
let log: Recorded[] = []
/** One record per outgoing request: verb, path+query and (parsed) JSON body. */
function recorder() {
return http.all(/\/api\//, async ({ request }) => {
const url = new URL(request.url)
const raw = await request.text()
let body: unknown
if (raw) {
try { body = JSON.parse(raw) } catch { body = raw }
}
log.push({ method: request.method, url: url.pathname + url.search, body })
return HttpResponse.json({ ok: true })
})
}
beforeEach(() => {
log = []
server.use(recorder())
// parseInDev/checkInDev warn on every stub payload that doesn't match its
// @trek/shared schema — expected here, so keep the output readable.
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
interface Call { n: string; r: () => Promise<unknown>; e: string }
/** Runs every call in isolation and checks the verb + path it produced. */
async function assertCalls(calls: Call[]): Promise<void> {
for (const c of calls) {
log = []
await c.r()
expect(log.length, `${c.n}: expected exactly one request`).toBe(1)
const rec = log[0]
const [path] = rec.url.split('?')
expect(`${rec.method} ${path}`, c.n).toBe(c.e)
}
}
/** Runs one call and returns the request it produced. */
async function traceOne(run: () => Promise<unknown>): Promise<Recorded> {
log = []
await run()
expect(log).toHaveLength(1)
return log[0]
}
describe('client > endpoint wiring', () => {
it('FE-APISURF-001: authApi maps every method to its auth endpoint', async () => {
await assertCalls([
{ n: 'register', r: () => authApi.register({ email: 'a@b.c', password: 'pw' }), e: 'POST /api/auth/register' },
{ n: 'validateInvite', r: () => authApi.validateInvite('inv-tok'), e: 'GET /api/auth/invite/inv-tok' },
{ n: 'login', r: () => authApi.login({ email: 'a@b.c', password: 'pw' }), e: 'POST /api/auth/login' },
{ n: 'verifyMfaLogin', r: () => authApi.verifyMfaLogin({ mfa_token: 'm', code: '123456' }), e: 'POST /api/auth/mfa/verify-login' },
{ n: 'mfaSetup', r: () => authApi.mfaSetup(), e: 'POST /api/auth/mfa/setup' },
{ n: 'mfaEnable', r: () => authApi.mfaEnable({ code: '123456' }), e: 'POST /api/auth/mfa/enable' },
{ n: 'mfaDisable', r: () => authApi.mfaDisable({ password: 'pw', code: '123456' }), e: 'POST /api/auth/mfa/disable' },
{ n: 'me', r: () => authApi.me(), e: 'GET /api/auth/me' },
{ n: 'updateMapsKey', r: () => authApi.updateMapsKey('gkey'), e: 'PUT /api/auth/me/maps-key' },
{ n: 'updateApiKeys', r: () => authApi.updateApiKeys({ google_maps: null }), e: 'PUT /api/auth/me/api-keys' },
{ n: 'updateSettings', r: () => authApi.updateSettings({ theme: 'dark' }), e: 'PUT /api/auth/me/settings' },
{ n: 'getSettings', r: () => authApi.getSettings(), e: 'GET /api/auth/me/settings' },
{ n: 'listUsers', r: () => authApi.listUsers(), e: 'GET /api/auth/users' },
{ n: 'deleteAvatar', r: () => authApi.deleteAvatar(), e: 'DELETE /api/auth/avatar' },
{ n: 'getAppConfig', r: () => authApi.getAppConfig(), e: 'GET /api/auth/app-config' },
{ n: 'updateAppSettings', r: () => authApi.updateAppSettings({ registration_enabled: true }), e: 'PUT /api/auth/app-settings' },
{ n: 'validateKeys', r: () => authApi.validateKeys(), e: 'GET /api/auth/validate-keys' },
{ n: 'travelStats', r: () => authApi.travelStats(), e: 'GET /api/auth/travel-stats' },
{ n: 'changePassword', r: () => authApi.changePassword({ current_password: 'a', new_password: 'b' }), e: 'PUT /api/auth/me/password' },
{ n: 'forgotPassword', r: () => authApi.forgotPassword({ email: 'a@b.c' }), e: 'POST /api/auth/forgot-password' },
{ n: 'resetPassword', r: () => authApi.resetPassword({ token: 't', new_password: 'b' }), e: 'POST /api/auth/reset-password' },
{ n: 'deleteOwnAccount', r: () => authApi.deleteOwnAccount(), e: 'DELETE /api/auth/me' },
{ n: 'demoLogin', r: () => authApi.demoLogin(), e: 'POST /api/auth/demo-login' },
{ n: 'mcpTokens.list', r: () => authApi.mcpTokens.list(), e: 'GET /api/auth/mcp-tokens' },
{ n: 'mcpTokens.create', r: () => authApi.mcpTokens.create('cli'), e: 'POST /api/auth/mcp-tokens' },
{ n: 'mcpTokens.delete', r: () => authApi.mcpTokens.delete(7), e: 'DELETE /api/auth/mcp-tokens/7' },
{ n: 'passkey.registerOptions', r: () => authApi.passkey.registerOptions('pw'), e: 'POST /api/auth/passkey/register/options' },
{ n: 'passkey.registerVerify', r: () => authApi.passkey.registerVerify({ id: 'cred' }, 'Yubikey'), e: 'POST /api/auth/passkey/register/verify' },
{ n: 'passkey.loginOptions', r: () => authApi.passkey.loginOptions(), e: 'POST /api/auth/passkey/login/options' },
{ n: 'passkey.loginVerify', r: () => authApi.passkey.loginVerify({ id: 'cred' }), e: 'POST /api/auth/passkey/login/verify' },
{ n: 'passkey.list', r: () => authApi.passkey.list(), e: 'GET /api/auth/passkey/credentials' },
{ n: 'passkey.rename', r: () => authApi.passkey.rename(3, 'Phone'), e: 'PATCH /api/auth/passkey/credentials/3' },
{ n: 'passkey.delete', r: () => authApi.passkey.delete(3, 'pw'), e: 'DELETE /api/auth/passkey/credentials/3' },
])
})
it('FE-APISURF-002: oauthApi maps consent + client/session management endpoints', async () => {
const params = {
response_type: 'code', client_id: 'cid', redirect_uri: 'https://app/cb',
scope: 'trips:read', code_challenge: 'chal', code_challenge_method: 'S256',
}
await assertCalls([
{ n: 'validate', r: () => oauthApi.validate(params), e: 'GET /api/oauth/authorize/validate' },
{ n: 'authorize', r: () => oauthApi.authorize({ ...params, approved: true }), e: 'POST /api/oauth/authorize' },
{ n: 'clients.list', r: () => oauthApi.clients.list(), e: 'GET /api/oauth/clients' },
{ n: 'clients.create', r: () => oauthApi.clients.create({ name: 'App', allowed_scopes: ['trips:read'] }), e: 'POST /api/oauth/clients' },
{ n: 'clients.rotate', r: () => oauthApi.clients.rotate('cid'), e: 'POST /api/oauth/clients/cid/rotate' },
{ n: 'clients.delete', r: () => oauthApi.clients.delete('cid'), e: 'DELETE /api/oauth/clients/cid' },
{ n: 'sessions.list', r: () => oauthApi.sessions.list(), e: 'GET /api/oauth/sessions' },
{ n: 'sessions.revoke', r: () => oauthApi.sessions.revoke(4), e: 'DELETE /api/oauth/sessions/4' },
])
})
it('FE-APISURF-003: tripsApi maps trip, member and guest endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => tripsApi.list(), e: 'GET /api/trips' },
{ n: 'create', r: () => tripsApi.create({ title: 'Rome' }), e: 'POST /api/trips' },
{ n: 'get', r: () => tripsApi.get(3), e: 'GET /api/trips/3' },
{ n: 'update', r: () => tripsApi.update(3, { title: 'Rome 2' }), e: 'PUT /api/trips/3' },
{ n: 'delete', r: () => tripsApi.delete(3), e: 'DELETE /api/trips/3' },
{ n: 'searchCoverImages', r: () => tripsApi.searchCoverImages('rome'), e: 'GET /api/trips/cover-images/search' },
{ n: 'archive', r: () => tripsApi.archive(3), e: 'PUT /api/trips/3' },
{ n: 'unarchive', r: () => tripsApi.unarchive(3), e: 'PUT /api/trips/3' },
{ n: 'getMembers', r: () => tripsApi.getMembers(3), e: 'GET /api/trips/3/members' },
{ n: 'addMember', r: () => tripsApi.addMember(3, 'bob'), e: 'POST /api/trips/3/members' },
{ n: 'removeMember', r: () => tripsApi.removeMember(3, 9), e: 'DELETE /api/trips/3/members/9' },
{ n: 'transferOwnership', r: () => tripsApi.transferOwnership(3, 9), e: 'POST /api/trips/3/transfer' },
{ n: 'createGuest', r: () => tripsApi.createGuest(3, 'Anna'), e: 'POST /api/trips/3/guests' },
{ n: 'renameGuest', r: () => tripsApi.renameGuest(3, 9, 'Ana'), e: 'PUT /api/trips/3/guests/9' },
{ n: 'deleteGuest', r: () => tripsApi.deleteGuest(3, 9), e: 'DELETE /api/trips/3/guests/9' },
{ n: 'copy', r: () => tripsApi.copy(3, { title: 'Copy' }), e: 'POST /api/trips/3/copy' },
{ n: 'bundle', r: () => tripsApi.bundle(3), e: 'GET /api/trips/3/bundle' },
])
})
it('FE-APISURF-004: daysApi and dayNotesApi map their nested trip endpoints', async () => {
await assertCalls([
{ n: 'days.list', r: () => daysApi.list(1), e: 'GET /api/trips/1/days' },
{ n: 'days.create', r: () => daysApi.create(1, { date: '2026-06-01' }), e: 'POST /api/trips/1/days' },
{ n: 'days.update', r: () => daysApi.update(1, 2, { notes: 'hi' }), e: 'PUT /api/trips/1/days/2' },
{ n: 'days.updateTransport', r: () => daysApi.updateTransport(1, 2, 'car'), e: 'PUT /api/trips/1/days/2/transport' },
{ n: 'days.delete', r: () => daysApi.delete(1, 2), e: 'DELETE /api/trips/1/days/2' },
{ n: 'days.reorder', r: () => daysApi.reorder(1, [2, 1]), e: 'PUT /api/trips/1/days/reorder' },
{ n: 'dayNotes.list', r: () => dayNotesApi.list(1, 2), e: 'GET /api/trips/1/days/2/notes' },
{ n: 'dayNotes.create', r: () => dayNotesApi.create(1, 2, { text: 'note' }), e: 'POST /api/trips/1/days/2/notes' },
{ n: 'dayNotes.update', r: () => dayNotesApi.update(1, 2, 5, { text: 'edit' }), e: 'PUT /api/trips/1/days/2/notes/5' },
{ n: 'dayNotes.delete', r: () => dayNotesApi.delete(1, 2, 5), e: 'DELETE /api/trips/1/days/2/notes/5' },
])
})
it('FE-APISURF-005: placesApi maps CRUD, rating and list-import endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => placesApi.list(1), e: 'GET /api/trips/1/places' },
{ n: 'create', r: () => placesApi.create(1, { name: 'Colosseum' }), e: 'POST /api/trips/1/places' },
{ n: 'get', r: () => placesApi.get(1, 5), e: 'GET /api/trips/1/places/5' },
{ n: 'update', r: () => placesApi.update(1, 5, { name: 'Forum' }), e: 'PUT /api/trips/1/places/5' },
{ n: 'delete', r: () => placesApi.delete(1, 5), e: 'DELETE /api/trips/1/places/5' },
{ n: 'searchImage', r: () => placesApi.searchImage(1, 5), e: 'GET /api/trips/1/places/5/image' },
{ n: 'importGoogleList', r: () => placesApi.importGoogleList(1, 'https://maps.app/x'), e: 'POST /api/trips/1/places/import/google-list' },
{ n: 'importNaverList', r: () => placesApi.importNaverList(1, 'https://naver/x'), e: 'POST /api/trips/1/places/import/naver-list' },
{ n: 'bulkDelete', r: () => placesApi.bulkDelete(1, [5, 6]), e: 'POST /api/trips/1/places/bulk-delete' },
{ n: 'bulkUpdate', r: () => placesApi.bulkUpdate(1, [5], { category_id: 2 }), e: 'POST /api/trips/1/places/bulk-update' },
])
})
it('FE-APISURF-006: assignmentsApi maps day-plan endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => assignmentsApi.list(1, 2), e: 'GET /api/trips/1/days/2/assignments' },
{ n: 'create', r: () => assignmentsApi.create(1, 2, { place_id: 5 }), e: 'POST /api/trips/1/days/2/assignments' },
{ n: 'delete', r: () => assignmentsApi.delete(1, 2, 7), e: 'DELETE /api/trips/1/days/2/assignments/7' },
{ n: 'reorder', r: () => assignmentsApi.reorder(1, 2, [7, 8]), e: 'PUT /api/trips/1/days/2/assignments/reorder' },
{ n: 'move', r: () => assignmentsApi.move(1, 7, 3, 0), e: 'PUT /api/trips/1/assignments/7/move' },
{ n: 'update', r: () => assignmentsApi.update(1, 2, 7, { notes: 'x' }), e: 'PUT /api/trips/1/days/2/assignments/7' },
{ n: 'getParticipants', r: () => assignmentsApi.getParticipants(1, 7), e: 'GET /api/trips/1/assignments/7/participants' },
{ n: 'setParticipants', r: () => assignmentsApi.setParticipants(1, 7, [4]), e: 'PUT /api/trips/1/assignments/7/participants' },
{ n: 'updateTime', r: () => assignmentsApi.updateTime(1, 7, { place_time: '09:00' }), e: 'PUT /api/trips/1/assignments/7/time' },
{ n: 'updateTransport', r: () => assignmentsApi.updateTransport(1, 7, null), e: 'PUT /api/trips/1/assignments/7/transport' },
])
})
it('FE-APISURF-007: packingApi maps item, bag and template endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => packingApi.list(1), e: 'GET /api/trips/1/packing' },
{ n: 'create', r: () => packingApi.create(1, { name: 'Towel' }), e: 'POST /api/trips/1/packing' },
{ n: 'bulkImport', r: () => packingApi.bulkImport(1, [{ name: 'Socks' }]), e: 'POST /api/trips/1/packing/import' },
{ n: 'update', r: () => packingApi.update(1, 4, { checked: true }), e: 'PUT /api/trips/1/packing/4' },
{ n: 'delete', r: () => packingApi.delete(1, 4), e: 'DELETE /api/trips/1/packing/4' },
{ n: 'reorder', r: () => packingApi.reorder(1, [4, 5]), e: 'PUT /api/trips/1/packing/reorder' },
{ n: 'setSharing', r: () => packingApi.setSharing(1, 4, { visibility: 'shared' }), e: 'PUT /api/trips/1/packing/4/sharing' },
{ n: 'clone', r: () => packingApi.clone(1, 4), e: 'POST /api/trips/1/packing/4/clone' },
{ n: 'addContributor', r: () => packingApi.addContributor(1, 4), e: 'POST /api/trips/1/packing/4/contributors' },
{ n: 'removeContributor', r: () => packingApi.removeContributor(1, 4, 9), e: 'DELETE /api/trips/1/packing/4/contributors/9' },
{ n: 'getCategoryAssignees', r: () => packingApi.getCategoryAssignees(1), e: 'GET /api/trips/1/packing/category-assignees' },
{ n: 'listTemplates', r: () => packingApi.listTemplates(1), e: 'GET /api/trips/1/packing/templates' },
{ n: 'applyTemplate', r: () => packingApi.applyTemplate(1, 6), e: 'POST /api/trips/1/packing/apply-template/6' },
{ n: 'saveAsTemplate', r: () => packingApi.saveAsTemplate(1, 'Beach'), e: 'POST /api/trips/1/packing/save-as-template' },
{ n: 'setBagMembers', r: () => packingApi.setBagMembers(1, 2, [9]), e: 'PUT /api/trips/1/packing/bags/2/members' },
{ n: 'listBags', r: () => packingApi.listBags(1), e: 'GET /api/trips/1/packing/bags' },
{ n: 'createBag', r: () => packingApi.createBag(1, { name: 'Carry-on' }), e: 'POST /api/trips/1/packing/bags' },
{ n: 'updateBag', r: () => packingApi.updateBag(1, 2, { name: 'Hold' }), e: 'PUT /api/trips/1/packing/bags/2' },
{ n: 'deleteBag', r: () => packingApi.deleteBag(1, 2), e: 'DELETE /api/trips/1/packing/bags/2' },
])
})
it('FE-APISURF-008: todoApi maps todo endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => todoApi.list(1), e: 'GET /api/trips/1/todo' },
{ n: 'create', r: () => todoApi.create(1, { name: 'Book train' }), e: 'POST /api/trips/1/todo' },
{ n: 'update', r: () => todoApi.update(1, 3, { checked: true }), e: 'PUT /api/trips/1/todo/3' },
{ n: 'delete', r: () => todoApi.delete(1, 3), e: 'DELETE /api/trips/1/todo/3' },
{ n: 'reorder', r: () => todoApi.reorder(1, [3, 4]), e: 'PUT /api/trips/1/todo/reorder' },
{ n: 'getCategoryAssignees', r: () => todoApi.getCategoryAssignees(1), e: 'GET /api/trips/1/todo/category-assignees' },
])
})
it('FE-APISURF-009: tagsApi and categoriesApi map their global endpoints', async () => {
await assertCalls([
{ n: 'tags.list', r: () => tagsApi.list(), e: 'GET /api/tags' },
{ n: 'tags.create', r: () => tagsApi.create({ name: 'Food' }), e: 'POST /api/tags' },
{ n: 'tags.update', r: () => tagsApi.update(2, { name: 'Eat' }), e: 'PUT /api/tags/2' },
{ n: 'tags.delete', r: () => tagsApi.delete(2), e: 'DELETE /api/tags/2' },
{ n: 'categories.list', r: () => categoriesApi.list(), e: 'GET /api/categories' },
{ n: 'categories.create', r: () => categoriesApi.create({ name: 'Museum' }), e: 'POST /api/categories' },
{ n: 'categories.update', r: () => categoriesApi.update(2, { name: 'Art' }), e: 'PUT /api/categories/2' },
{ n: 'categories.delete', r: () => categoriesApi.delete(2), e: 'DELETE /api/categories/2' },
])
})
it('FE-APISURF-010: adminApi maps user, addon and settings endpoints', async () => {
await assertCalls([
{ n: 'users', r: () => adminApi.users(), e: 'GET /api/admin/users' },
{ n: 'createUser', r: () => adminApi.createUser({ email: 'a@b.c' }), e: 'POST /api/admin/users' },
{ n: 'updateUser', r: () => adminApi.updateUser(2, { role: 'admin' }), e: 'PUT /api/admin/users/2' },
{ n: 'deleteUser', r: () => adminApi.deleteUser(2), e: 'DELETE /api/admin/users/2' },
{ n: 'resetUserPasskeys', r: () => adminApi.resetUserPasskeys(2), e: 'DELETE /api/admin/users/2/passkeys' },
{ n: 'stats', r: () => adminApi.stats(), e: 'GET /api/admin/stats' },
{ n: 'saveDemoBaseline', r: () => adminApi.saveDemoBaseline(), e: 'POST /api/admin/save-demo-baseline' },
{ n: 'getOidc', r: () => adminApi.getOidc(), e: 'GET /api/admin/oidc' },
{ n: 'updateOidc', r: () => adminApi.updateOidc({ enabled: true }), e: 'PUT /api/admin/oidc' },
{ n: 'addons', r: () => adminApi.addons(), e: 'GET /api/admin/addons' },
{ n: 'updateAddon', r: () => adminApi.updateAddon(3, { enabled: false }), e: 'PUT /api/admin/addons/3' },
{ n: 'checkVersion', r: () => adminApi.checkVersion(), e: 'GET /api/admin/version-check' },
{ n: 'getBagTracking', r: () => adminApi.getBagTracking(), e: 'GET /api/admin/bag-tracking' },
{ n: 'updateBagTracking', r: () => adminApi.updateBagTracking(true), e: 'PUT /api/admin/bag-tracking' },
{ n: 'getPlacesPhotos', r: () => adminApi.getPlacesPhotos(), e: 'GET /api/admin/places-photos' },
{ n: 'updatePlacesPhotos', r: () => adminApi.updatePlacesPhotos(false), e: 'PUT /api/admin/places-photos' },
{ n: 'getPlacesAutocomplete', r: () => adminApi.getPlacesAutocomplete(), e: 'GET /api/admin/places-autocomplete' },
{ n: 'updatePlacesAutocomplete', r: () => adminApi.updatePlacesAutocomplete(true), e: 'PUT /api/admin/places-autocomplete' },
{ n: 'getPlacesDetails', r: () => adminApi.getPlacesDetails(), e: 'GET /api/admin/places-details' },
{ n: 'updatePlacesDetails', r: () => adminApi.updatePlacesDetails(true), e: 'PUT /api/admin/places-details' },
{ n: 'getCollabFeatures', r: () => adminApi.getCollabFeatures(), e: 'GET /api/admin/collab-features' },
{ n: 'updateCollabFeatures', r: () => adminApi.updateCollabFeatures({ polls: true }), e: 'PUT /api/admin/collab-features' },
{ n: 'getPermissions', r: () => adminApi.getPermissions(), e: 'GET /api/admin/permissions' },
{ n: 'updatePermissions', r: () => adminApi.updatePermissions({ edit_trip: 'member' }), e: 'PUT /api/admin/permissions' },
{ n: 'rotateJwtSecret', r: () => adminApi.rotateJwtSecret(), e: 'POST /api/admin/rotate-jwt-secret' },
{ n: 'sendTestNotification', r: () => adminApi.sendTestNotification({ channel: 'email' }), e: 'POST /api/admin/dev/test-notification' },
{ n: 'getNotificationPreferences', r: () => adminApi.getNotificationPreferences(), e: 'GET /api/admin/notification-preferences' },
{ n: 'updateNotificationPreferences', r: () => adminApi.updateNotificationPreferences({ email: { trip_invite: true } }), e: 'PUT /api/admin/notification-preferences' },
{ n: 'getDefaultUserSettings', r: () => adminApi.getDefaultUserSettings(), e: 'GET /api/admin/default-user-settings' },
{ n: 'updateDefaultUserSettings', r: () => adminApi.updateDefaultUserSettings({ language: 'de' }), e: 'PUT /api/admin/default-user-settings' },
{ n: 'mcpTokens', r: () => adminApi.mcpTokens(), e: 'GET /api/admin/mcp-tokens' },
{ n: 'deleteMcpToken', r: () => adminApi.deleteMcpToken(4), e: 'DELETE /api/admin/mcp-tokens/4' },
{ n: 'oauthSessions', r: () => adminApi.oauthSessions(), e: 'GET /api/admin/oauth-sessions' },
{ n: 'revokeOAuthSession', r: () => adminApi.revokeOAuthSession(4), e: 'DELETE /api/admin/oauth-sessions/4' },
{ n: 'listInvites', r: () => adminApi.listInvites(), e: 'GET /api/admin/invites' },
{ n: 'listInviteTrips', r: () => adminApi.listInviteTrips(), e: 'GET /api/admin/invites/trips' },
{ n: 'createInvite', r: () => adminApi.createInvite({ max_uses: 3 }), e: 'POST /api/admin/invites' },
{ n: 'deleteInvite', r: () => adminApi.deleteInvite(8), e: 'DELETE /api/admin/invites/8' },
{ n: 'auditLog', r: () => adminApi.auditLog(), e: 'GET /api/admin/audit-log' },
])
})
it('FE-APISURF-011: adminApi maps the plugin management endpoints', async () => {
await assertCalls([
{ n: 'plugins', r: () => adminApi.plugins(), e: 'GET /api/admin/plugins' },
{ n: 'pluginBrowse', r: () => adminApi.pluginBrowse(), e: 'GET /api/admin/plugins/registry' },
{ n: 'pluginDetail', r: () => adminApi.pluginDetail('trek/koffi'), e: 'GET /api/admin/plugins/registry/trek%2Fkoffi' },
{ n: 'pluginInstall', r: () => adminApi.pluginInstall('koffi', { version: '1.0.0' }), e: 'POST /api/admin/plugins/install' },
{ n: 'pluginActivate', r: () => adminApi.pluginActivate('koffi'), e: 'POST /api/admin/plugins/koffi/activate' },
{ n: 'pluginDeactivate', r: () => adminApi.pluginDeactivate('koffi'), e: 'POST /api/admin/plugins/koffi/deactivate' },
{ n: 'pluginUpdate', r: () => adminApi.pluginUpdate('koffi'), e: 'POST /api/admin/plugins/koffi/update' },
{ n: 'pluginRetrust', r: () => adminApi.pluginRetrust('koffi', '2.0.0', 'PUBKEY'), e: 'POST /api/admin/plugins/koffi/retrust' },
{ n: 'pluginUninstall', r: () => adminApi.pluginUninstall('koffi', true), e: 'POST /api/admin/plugins/koffi/uninstall' },
{ n: 'pluginRescan', r: () => adminApi.pluginRescan(), e: 'POST /api/admin/plugins/rescan' },
{ n: 'pluginLink', r: () => adminApi.pluginLink('/srv/plugin'), e: 'POST /api/admin/plugins/link' },
{ n: 'pluginReload', r: () => adminApi.pluginReload('koffi'), e: 'POST /api/admin/plugins/koffi/reload' },
{ n: 'pluginEgressHosts', r: () => adminApi.pluginEgressHosts('koffi'), e: 'GET /api/admin/plugins/koffi/egress-hosts' },
{ n: 'pluginSetEgressHosts', r: () => adminApi.pluginSetEgressHosts('koffi', ['a.example']), e: 'PUT /api/admin/plugins/koffi/egress-hosts' },
{ n: 'pluginErrors', r: () => adminApi.pluginErrors('koffi'), e: 'GET /api/admin/plugins/koffi/errors' },
{ n: 'pluginAudit', r: () => adminApi.pluginAudit('koffi'), e: 'GET /api/admin/plugins/koffi/audit' },
{ n: 'llmLocalModels', r: () => adminApi.llmLocalModels('http://ollama:11434'), e: 'GET /api/admin/llm/local/models' },
])
})
it('FE-APISURF-012: adminApi maps the packing-template endpoints', async () => {
await assertCalls([
{ n: 'packingTemplates', r: () => adminApi.packingTemplates(), e: 'GET /api/admin/packing-templates' },
{ n: 'getPackingTemplate', r: () => adminApi.getPackingTemplate(1), e: 'GET /api/admin/packing-templates/1' },
{ n: 'createPackingTemplate', r: () => adminApi.createPackingTemplate({ name: 'Ski' }), e: 'POST /api/admin/packing-templates' },
{ n: 'updatePackingTemplate', r: () => adminApi.updatePackingTemplate(1, { name: 'Ski 2' }), e: 'PUT /api/admin/packing-templates/1' },
{ n: 'deletePackingTemplate', r: () => adminApi.deletePackingTemplate(1), e: 'DELETE /api/admin/packing-templates/1' },
{ n: 'addTemplateCategory', r: () => adminApi.addTemplateCategory(1, { name: 'Clothes' }), e: 'POST /api/admin/packing-templates/1/categories' },
{ n: 'updateTemplateCategory', r: () => adminApi.updateTemplateCategory(1, 2, { name: 'Wear' }), e: 'PUT /api/admin/packing-templates/1/categories/2' },
{ n: 'deleteTemplateCategory', r: () => adminApi.deleteTemplateCategory(1, 2), e: 'DELETE /api/admin/packing-templates/1/categories/2' },
{ n: 'addTemplateItem', r: () => adminApi.addTemplateItem(1, 2, { name: 'Gloves' }), e: 'POST /api/admin/packing-templates/1/categories/2/items' },
{ n: 'updateTemplateItem', r: () => adminApi.updateTemplateItem(1, 3, { name: 'Mittens' }), e: 'PUT /api/admin/packing-templates/1/items/3' },
{ n: 'deleteTemplateItem', r: () => adminApi.deleteTemplateItem(1, 3), e: 'DELETE /api/admin/packing-templates/1/items/3' },
])
})
it('FE-APISURF-013: pluginsApi maps every host-mediated plugin endpoint', async () => {
await assertCalls([
{ n: 'active', r: () => pluginsApi.active(), e: 'GET /api/plugins' },
{ n: 'placeDetails', r: () => pluginsApi.placeDetails(5), e: 'GET /api/place-details/5' },
{ n: 'tripWarnings', r: () => pluginsApi.tripWarnings(1), e: 'GET /api/trip-warnings/1' },
{ n: 'viewContributions', r: () => pluginsApi.viewContributions('places', 1), e: 'GET /api/view-contributions/places/1' },
{ n: 'mapMarkers', r: () => pluginsApi.mapMarkers(1), e: 'GET /api/map-markers/1' },
{ n: 'mapLayers', r: () => pluginsApi.mapLayers(1), e: 'GET /api/map-layers/1' },
{ n: 'pluginRoute', r: () => pluginsApi.pluginRoute('koffi', 'ev', { tripId: 1, waypoints: [{ lat: 1, lng: 2 }] }), e: 'POST /api/plugin-routes/koffi/ev' },
{ n: 'daySchedule', r: () => pluginsApi.daySchedule(1), e: 'GET /api/day-schedule/1' },
{ n: 'pdfSections', r: () => pluginsApi.pdfSections(1), e: 'GET /api/pdf-sections/1' },
{ n: 'atlasLayers', r: () => pluginsApi.atlasLayers(), e: 'GET /api/atlas-layers' },
{ n: 'journalEntryRows', r: () => pluginsApi.journalEntryRows(9), e: 'GET /api/journal-entry-rows/9' },
{ n: 'tripCardContributions', r: () => pluginsApi.tripCardContributions([1, 2]), e: 'GET /api/trip-card-contributions' },
{ n: 'myActivity', r: () => pluginsApi.myActivity(), e: 'GET /api/plugin-activity' },
{ n: 'userSettings', r: () => pluginsApi.userSettings('koffi'), e: 'GET /api/plugin-settings/koffi' },
{ n: 'runAction', r: () => pluginsApi.runAction('koffi', 'test connection'), e: 'POST /api/plugin-settings/koffi/actions/test%20connection' },
{ n: 'saveUserSettings', r: () => pluginsApi.saveUserSettings('koffi', { key: 'v' }), e: 'POST /api/plugin-settings/koffi' },
{ n: 'oauthStatus', r: () => pluginsApi.oauthStatus('koffi'), e: 'GET /api/plugin-oauth/koffi/status' },
{ n: 'oauthConnect', r: () => pluginsApi.oauthConnect('koffi'), e: 'POST /api/plugin-oauth/koffi/connect' },
{ n: 'oauthDisconnect', r: () => pluginsApi.oauthDisconnect('koffi'), e: 'POST /api/plugin-oauth/koffi/disconnect' },
])
})
it('FE-APISURF-014: airtrailApi maps the integration endpoints', async () => {
await assertCalls([
{ n: 'getSettings', r: () => airtrailApi.getSettings(), e: 'GET /api/integrations/airtrail/settings' },
{ n: 'saveSettings', r: () => airtrailApi.saveSettings({ url: 'https://at' }), e: 'PUT /api/integrations/airtrail/settings' },
{ n: 'status', r: () => airtrailApi.status(), e: 'GET /api/integrations/airtrail/status' },
{ n: 'test', r: () => airtrailApi.test({ url: 'https://at' }), e: 'POST /api/integrations/airtrail/test' },
{ n: 'sync', r: () => airtrailApi.sync(), e: 'POST /api/integrations/airtrail/sync' },
{ n: 'flights', r: () => airtrailApi.flights(), e: 'GET /api/integrations/airtrail/flights' },
{ n: 'import', r: () => airtrailApi.import(1, ['f1']), e: 'POST /api/trips/1/reservations/import/airtrail' },
])
})
it('FE-APISURF-015: journeyApi maps journal, entry and photo endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => journeyApi.list(), e: 'GET /api/journeys' },
{ n: 'create', r: () => journeyApi.create({ title: 'Asia' }), e: 'POST /api/journeys' },
{ n: 'get', r: () => journeyApi.get(2), e: 'GET /api/journeys/2' },
{ n: 'update', r: () => journeyApi.update(2, { title: 'Asia 24' }), e: 'PATCH /api/journeys/2' },
{ n: 'delete', r: () => journeyApi.delete(2), e: 'DELETE /api/journeys/2' },
{ n: 'suggestions', r: () => journeyApi.suggestions(), e: 'GET /api/journeys/suggestions' },
{ n: 'availableTrips', r: () => journeyApi.availableTrips(), e: 'GET /api/journeys/available-trips' },
{ n: 'addTrip', r: () => journeyApi.addTrip(2, 1), e: 'POST /api/journeys/2/trips' },
{ n: 'removeTrip', r: () => journeyApi.removeTrip(2, 1), e: 'DELETE /api/journeys/2/trips/1' },
{ n: 'listEntries', r: () => journeyApi.listEntries(2), e: 'GET /api/journeys/2/entries' },
{ n: 'createEntry', r: () => journeyApi.createEntry(2, { title: 'Day 1' }), e: 'POST /api/journeys/2/entries' },
{ n: 'updateEntry', r: () => journeyApi.updateEntry(9, { title: 'Day 2' }), e: 'PATCH /api/journeys/entries/9' },
{ n: 'deleteEntry', r: () => journeyApi.deleteEntry(9), e: 'DELETE /api/journeys/entries/9' },
{ n: 'reorderEntries', r: () => journeyApi.reorderEntries(2, [9, 8]), e: 'PUT /api/journeys/2/entries/reorder' },
{ n: 'addProviderPhotosToGallery', r: () => journeyApi.addProviderPhotosToGallery(2, 'immich', ['a1']), e: 'POST /api/journeys/2/gallery/provider-photos' },
{ n: 'addProviderPhoto', r: () => journeyApi.addProviderPhoto(9, 'immich', 'a1'), e: 'POST /api/journeys/entries/9/provider-photos' },
{ n: 'addProviderPhotos', r: () => journeyApi.addProviderPhotos(9, 'immich', ['a1']), e: 'POST /api/journeys/entries/9/provider-photos' },
{ n: 'linkPhoto', r: () => journeyApi.linkPhoto(9, 11), e: 'POST /api/journeys/entries/9/link-photo' },
{ n: 'unlinkPhoto', r: () => journeyApi.unlinkPhoto(9, 11), e: 'DELETE /api/journeys/entries/9/photos/11' },
{ n: 'deleteGalleryPhoto', r: () => journeyApi.deleteGalleryPhoto(2, 11), e: 'DELETE /api/journeys/2/gallery/11' },
{ n: 'updatePhoto', r: () => journeyApi.updatePhoto(11, { caption: 'x' }), e: 'PATCH /api/journeys/photos/11' },
{ n: 'deletePhoto', r: () => journeyApi.deletePhoto(11), e: 'DELETE /api/journeys/photos/11' },
{ n: 'addContributor', r: () => journeyApi.addContributor(2, 4, 'editor'), e: 'POST /api/journeys/2/contributors' },
{ n: 'updateContributor', r: () => journeyApi.updateContributor(2, 4, 'viewer'), e: 'PATCH /api/journeys/2/contributors/4' },
{ n: 'removeContributor', r: () => journeyApi.removeContributor(2, 4), e: 'DELETE /api/journeys/2/contributors/4' },
{ n: 'updatePreferences', r: () => journeyApi.updatePreferences(2, { hide_skeletons: true }), e: 'PATCH /api/journeys/2/preferences' },
{ n: 'getShareLink', r: () => journeyApi.getShareLink(2), e: 'GET /api/journeys/2/share-link' },
{ n: 'createShareLink', r: () => journeyApi.createShareLink(2, { share_map: true }), e: 'POST /api/journeys/2/share-link' },
{ n: 'deleteShareLink', r: () => journeyApi.deleteShareLink(2), e: 'DELETE /api/journeys/2/share-link' },
{ n: 'getPublicJourney', r: () => journeyApi.getPublicJourney('pub-tok'), e: 'GET /api/public/journey/pub-tok' },
])
})
it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => {
await assertCalls([
{ n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' },
{ n: 'maps.autocomplete', r: () => mapsApi.autocomplete('Rom'), e: 'POST /api/maps/autocomplete' },
{ n: 'maps.details', r: () => mapsApi.details('place/1'), e: 'GET /api/maps/details/place%2F1' },
{ n: 'maps.placePhoto', r: () => mapsApi.placePhoto('place/1'), e: 'GET /api/maps/place-photo/place%2F1' },
{ n: 'maps.reverse', r: () => mapsApi.reverse(41.9, 12.5), e: 'GET /api/maps/reverse' },
{ n: 'maps.resolveUrl', r: () => mapsApi.resolveUrl('https://maps.app.goo.gl/x'), e: 'POST /api/maps/resolve-url' },
{ n: 'maps.pois', r: () => mapsApi.pois('cafe', { south: 1, west: 2, north: 3, east: 4 }), e: 'GET /api/maps/pois' },
{ n: 'airports.search', r: () => airportsApi.search('BER'), e: 'GET /api/airports/search' },
{ n: 'airports.byIata', r: () => airportsApi.byIata('b/er'), e: 'GET /api/airports/b%2Fer' },
])
})
it('FE-APISURF-017: budgetApi maps item, member and settlement endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => budgetApi.list(1), e: 'GET /api/trips/1/budget' },
{ n: 'create', r: () => budgetApi.create(1, { name: 'Hotel' }), e: 'POST /api/trips/1/budget' },
{ n: 'update', r: () => budgetApi.update(1, 2, { name: 'Hostel' }), e: 'PUT /api/trips/1/budget/2' },
{ n: 'delete', r: () => budgetApi.delete(1, 2), e: 'DELETE /api/trips/1/budget/2' },
{ n: 'setMembers', r: () => budgetApi.setMembers(1, 2, [4, 5]), e: 'PUT /api/trips/1/budget/2/members' },
{ n: 'togglePaid', r: () => budgetApi.togglePaid(1, 2, 4, true), e: 'PUT /api/trips/1/budget/2/members/4/paid' },
{ n: 'setPayers', r: () => budgetApi.setPayers(1, 2, [{ user_id: 4, amount: 10 }]), e: 'PUT /api/trips/1/budget/2/payers' },
{ n: 'perPersonSummary', r: () => budgetApi.perPersonSummary(1), e: 'GET /api/trips/1/budget/summary/per-person' },
{ n: 'settlement', r: () => budgetApi.settlement(1), e: 'GET /api/trips/1/budget/settlement' },
{ n: 'createSettlement', r: () => budgetApi.createSettlement(1, { from_user_id: 4, to_user_id: 5, amount: 10 }), e: 'POST /api/trips/1/budget/settlements' },
{ n: 'updateSettlement', r: () => budgetApi.updateSettlement(1, 6, { from_user_id: 4, to_user_id: 5, amount: 12 }), e: 'PUT /api/trips/1/budget/settlements/6' },
{ n: 'deleteSettlement', r: () => budgetApi.deleteSettlement(1, 6), e: 'DELETE /api/trips/1/budget/settlements/6' },
{ n: 'reorderItems', r: () => budgetApi.reorderItems(1, [2, 3]), e: 'PUT /api/trips/1/budget/reorder/items' },
{ n: 'reorderCategories', r: () => budgetApi.reorderCategories(1, ['Food']), e: 'PUT /api/trips/1/budget/reorder/categories' },
])
})
it('FE-APISURF-018: filesApi maps file, trash and link endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => filesApi.list(1), e: 'GET /api/trips/1/files' },
{ n: 'update', r: () => filesApi.update(1, 3, { description: 'x' }), e: 'PUT /api/trips/1/files/3' },
{ n: 'delete', r: () => filesApi.delete(1, 3), e: 'DELETE /api/trips/1/files/3' },
{ n: 'toggleStar', r: () => filesApi.toggleStar(1, 3), e: 'PATCH /api/trips/1/files/3/star' },
{ n: 'restore', r: () => filesApi.restore(1, 3), e: 'POST /api/trips/1/files/3/restore' },
{ n: 'permanentDelete', r: () => filesApi.permanentDelete(1, 3), e: 'DELETE /api/trips/1/files/3/permanent' },
{ n: 'emptyTrash', r: () => filesApi.emptyTrash(1), e: 'DELETE /api/trips/1/files/trash/empty' },
{ n: 'addLink', r: () => filesApi.addLink(1, 3, { place_id: 5 }), e: 'POST /api/trips/1/files/3/link' },
{ n: 'removeLink', r: () => filesApi.removeLink(1, 3, 7), e: 'DELETE /api/trips/1/files/3/link/7' },
{ n: 'getLinks', r: () => filesApi.getLinks(1, 3), e: 'GET /api/trips/1/files/3/links' },
])
})
it('FE-APISURF-019: reservationsApi and accommodationsApi map booking endpoints', async () => {
await assertCalls([
{ n: 'reservations.list', r: () => reservationsApi.list(1), e: 'GET /api/trips/1/reservations' },
{ n: 'reservations.upcoming', r: () => reservationsApi.upcoming(), e: 'GET /api/reservations/upcoming' },
{ n: 'reservations.create', r: () => reservationsApi.create(1, { title: 'Hotel' }), e: 'POST /api/trips/1/reservations' },
{ n: 'reservations.update', r: () => reservationsApi.update(1, 2, { title: 'Hostel' }), e: 'PUT /api/trips/1/reservations/2' },
{ n: 'reservations.delete', r: () => reservationsApi.delete(1, 2), e: 'DELETE /api/trips/1/reservations/2' },
{ n: 'reservations.setTravelers', r: () => reservationsApi.setTravelers(1, 2, [4]), e: 'PUT /api/trips/1/reservations/2/travelers' },
{ n: 'reservations.updatePositions', r: () => reservationsApi.updatePositions(1, [{ id: 2, day_plan_position: 0 }], 3), e: 'PUT /api/trips/1/reservations/positions' },
{ n: 'reservations.importBookingConfirm', r: () => reservationsApi.importBookingConfirm(1, []), e: 'POST /api/trips/1/reservations/import/booking/confirm' },
{ n: 'reservations.importJobStatus', r: () => reservationsApi.importJobStatus(1, 'job-1'), e: 'GET /api/trips/1/reservations/import/jobs/job-1' },
{ n: 'accommodations.list', r: () => accommodationsApi.list(1), e: 'GET /api/trips/1/accommodations' },
{ n: 'accommodations.create', r: () => accommodationsApi.create(1, { place_id: 5, start_day_id: 1, end_day_id: 2 }), e: 'POST /api/trips/1/accommodations' },
{ n: 'accommodations.update', r: () => accommodationsApi.update(1, 4, { end_day_id: 3 }), e: 'PUT /api/trips/1/accommodations/4' },
{ n: 'accommodations.delete', r: () => accommodationsApi.delete(1, 4), e: 'DELETE /api/trips/1/accommodations/4' },
])
})
it('FE-APISURF-020: collabApi maps note, poll and message endpoints', async () => {
await assertCalls([
{ n: 'getNotes', r: () => collabApi.getNotes(1), e: 'GET /api/trips/1/collab/notes' },
{ n: 'createNote', r: () => collabApi.createNote(1, { title: 'Ideas' }), e: 'POST /api/trips/1/collab/notes' },
{ n: 'updateNote', r: () => collabApi.updateNote(1, 2, { title: 'More' }), e: 'PUT /api/trips/1/collab/notes/2' },
{ n: 'deleteNote', r: () => collabApi.deleteNote(1, 2), e: 'DELETE /api/trips/1/collab/notes/2' },
{ n: 'deleteNoteFile', r: () => collabApi.deleteNoteFile(1, 2, 3), e: 'DELETE /api/trips/1/collab/notes/2/files/3' },
{ n: 'getPolls', r: () => collabApi.getPolls(1), e: 'GET /api/trips/1/collab/polls' },
{ n: 'createPoll', r: () => collabApi.createPoll(1, { question: 'Where?', options: ['A', 'B'] }), e: 'POST /api/trips/1/collab/polls' },
{ n: 'votePoll', r: () => collabApi.votePoll(1, 2, 1), e: 'POST /api/trips/1/collab/polls/2/vote' },
{ n: 'closePoll', r: () => collabApi.closePoll(1, 2), e: 'PUT /api/trips/1/collab/polls/2/close' },
{ n: 'deletePoll', r: () => collabApi.deletePoll(1, 2), e: 'DELETE /api/trips/1/collab/polls/2' },
{ n: 'getMessages', r: () => collabApi.getMessages(1), e: 'GET /api/trips/1/collab/messages' },
{ n: 'sendMessage', r: () => collabApi.sendMessage(1, { text: 'hi' }), e: 'POST /api/trips/1/collab/messages' },
{ n: 'deleteMessage', r: () => collabApi.deleteMessage(1, 2), e: 'DELETE /api/trips/1/collab/messages/2' },
{ n: 'reactMessage', r: () => collabApi.reactMessage(1, 2, '👍'), e: 'POST /api/trips/1/collab/messages/2/react' },
{ n: 'linkPreview', r: () => collabApi.linkPreview(1, 'https://x.test/a?b=1'), e: 'GET /api/trips/1/collab/link-preview' },
])
})
it('FE-APISURF-021: the remaining namespaces map their endpoints', async () => {
await assertCalls([
{ n: 'addons.enabled', r: () => addonsApi.enabled(), e: 'GET /api/addons' },
{ n: 'health.features', r: () => healthApi.features(), e: 'GET /api/health/features' },
{ n: 'weather.get', r: () => weatherApi.get(41.9, 12.5, '2026-06-01'), e: 'GET /api/weather' },
{ n: 'weather.getCurrent', r: () => weatherApi.getCurrent(41.9, 12.5), e: 'GET /api/weather' },
{ n: 'weather.getDetailed', r: () => weatherApi.getDetailed(41.9, 12.5, '2026-06-01'), e: 'GET /api/weather/detailed' },
{ n: 'config.getPublicConfig', r: () => configApi.getPublicConfig(), e: 'GET /api/config' },
{ n: 'help.index', r: () => helpApi.index(), e: 'GET /api/help/index' },
{ n: 'help.page', r: () => helpApi.page('getting started'), e: 'GET /api/help/page/getting%20started' },
{ n: 'settings.get', r: () => settingsApi.get(), e: 'GET /api/settings' },
{ n: 'settings.set', r: () => settingsApi.set('theme', 'dark'), e: 'PUT /api/settings' },
{ n: 'settings.setBulk', r: () => settingsApi.setBulk({ theme: 'dark' }), e: 'POST /api/settings/bulk' },
{ n: 'backup.list', r: () => backupApi.list(), e: 'GET /api/backup/list' },
{ n: 'backup.create', r: () => backupApi.create(), e: 'POST /api/backup/create' },
{ n: 'backup.delete', r: () => backupApi.delete('b.zip'), e: 'DELETE /api/backup/b.zip' },
{ n: 'backup.restore', r: () => backupApi.restore('b.zip'), e: 'POST /api/backup/restore/b.zip' },
{ n: 'backup.getAutoSettings', r: () => backupApi.getAutoSettings(), e: 'GET /api/backup/auto-settings' },
{ n: 'backup.setAutoSettings', r: () => backupApi.setAutoSettings({ enabled: true }), e: 'PUT /api/backup/auto-settings' },
{ n: 'share.getLink', r: () => shareApi.getLink(1), e: 'GET /api/trips/1/share-link' },
{ n: 'share.createLink', r: () => shareApi.createLink(1, { edit: false }), e: 'POST /api/trips/1/share-link' },
{ n: 'share.deleteLink', r: () => shareApi.deleteLink(1), e: 'DELETE /api/trips/1/share-link' },
{ n: 'share.getSharedTrip', r: () => shareApi.getSharedTrip('tok'), e: 'GET /api/shared/tok' },
{ n: 'transit.geocode', r: () => transitApi.geocode('Roma Termini'), e: 'GET /api/transit/geocode' },
{ n: 'transit.plan', r: () => transitApi.plan({ from: 'a', to: 'b' }), e: 'GET /api/transit/plan' },
{ n: 'tripInvite.getLink', r: () => tripInviteApi.getLink(1), e: 'GET /api/trips/1/invite-link' },
{ n: 'tripInvite.createLink', r: () => tripInviteApi.createLink(1, 7), e: 'POST /api/trips/1/invite-link' },
{ n: 'tripInvite.deleteLink', r: () => tripInviteApi.deleteLink(1), e: 'DELETE /api/trips/1/invite-link' },
{ n: 'tripInvite.preview', r: () => tripInviteApi.preview('tok'), e: 'GET /api/trip-invites/tok' },
{ n: 'tripInvite.accept', r: () => tripInviteApi.accept('tok'), e: 'POST /api/trip-invites/tok/accept' },
{ n: 'notifications.getPreferences', r: () => notificationsApi.getPreferences(), e: 'GET /api/notifications/preferences' },
{ n: 'notifications.updatePreferences', r: () => notificationsApi.updatePreferences({ email: { trip_invite: true } }), e: 'PUT /api/notifications/preferences' },
{ n: 'notifications.testSmtp', r: () => notificationsApi.testSmtp('a@b.c'), e: 'POST /api/notifications/test-smtp' },
{ n: 'notifications.testWebhook', r: () => notificationsApi.testWebhook('https://hook'), e: 'POST /api/notifications/test-webhook' },
{ n: 'notifications.testNtfy', r: () => notificationsApi.testNtfy({ topic: 't' }), e: 'POST /api/notifications/test-ntfy' },
{ n: 'notifications.testChannel', r: () => notificationsApi.testChannel('plugin/ch'), e: 'POST /api/notifications/test/plugin%2Fch' },
{ n: 'inApp.list', r: () => inAppNotificationsApi.list(), e: 'GET /api/notifications/in-app' },
{ n: 'inApp.unreadCount', r: () => inAppNotificationsApi.unreadCount(), e: 'GET /api/notifications/in-app/unread-count' },
{ n: 'inApp.markRead', r: () => inAppNotificationsApi.markRead(3), e: 'PUT /api/notifications/in-app/3/read' },
{ n: 'inApp.markUnread', r: () => inAppNotificationsApi.markUnread(3), e: 'PUT /api/notifications/in-app/3/unread' },
{ n: 'inApp.markAllRead', r: () => inAppNotificationsApi.markAllRead(), e: 'PUT /api/notifications/in-app/read-all' },
{ n: 'inApp.delete', r: () => inAppNotificationsApi.delete(3), e: 'DELETE /api/notifications/in-app/3' },
{ n: 'inApp.deleteAll', r: () => inAppNotificationsApi.deleteAll(), e: 'DELETE /api/notifications/in-app/all' },
{ n: 'inApp.respond', r: () => inAppNotificationsApi.respond(3, 'positive'), e: 'POST /api/notifications/in-app/3/respond' },
])
})
})
describe('client > request payloads', () => {
it('FE-APISURF-022: reorder helpers wrap their ids in the contract field', async () => {
expect((await traceOne(() => daysApi.reorder(1, [3, 1, 2]))).body).toEqual({ orderedIds: [3, 1, 2] })
expect((await traceOne(() => packingApi.reorder(1, [2, 1]))).body).toEqual({ orderedIds: [2, 1] })
expect((await traceOne(() => todoApi.reorder(1, [9]))).body).toEqual({ orderedIds: [9] })
expect((await traceOne(() => budgetApi.reorderItems(1, [4, 5]))).body).toEqual({ orderedIds: [4, 5] })
expect((await traceOne(() => budgetApi.reorderCategories(1, ['Food', 'Fun']))).body)
.toEqual({ orderedCategories: ['Food', 'Fun'] })
expect((await traceOne(() => journeyApi.reorderEntries(2, [8, 7]))).body).toEqual({ orderedIds: [8, 7] })
})
it('FE-APISURF-023: user-id collections are sent as user_ids', async () => {
expect((await traceOne(() => assignmentsApi.setParticipants(1, 7, [4, 5]))).body).toEqual({ user_ids: [4, 5] })
expect((await traceOne(() => budgetApi.setMembers(1, 2, [4]))).body).toEqual({ user_ids: [4] })
expect((await traceOne(() => packingApi.setBagMembers(1, 2, [6]))).body).toEqual({ user_ids: [6] })
expect((await traceOne(() => reservationsApi.setTravelers(1, 2, [4, 6]))).body).toEqual({ user_ids: [4, 6] })
})
it('FE-APISURF-024: single-value helpers wrap their argument in the documented key', async () => {
expect((await traceOne(() => authApi.updateMapsKey(null))).body).toEqual({ maps_api_key: null })
expect((await traceOne(() => tripsApi.addMember(1, 'bob@x.test'))).body).toEqual({ identifier: 'bob@x.test' })
expect((await traceOne(() => tripsApi.transferOwnership(1, 9))).body).toEqual({ newOwnerId: 9 })
expect((await traceOne(() => tripsApi.createGuest(1, 'Anna'))).body).toEqual({ name: 'Anna' })
expect((await traceOne(() => daysApi.updateTransport(1, 2, 'walk'))).body).toEqual({ transport_mode: 'walk' })
expect((await traceOne(() => assignmentsApi.updateTransport(1, 7, null))).body).toEqual({ transport_mode: null })
expect((await traceOne(() => collabApi.votePoll(1, 2, 3))).body).toEqual({ option_index: 3 })
expect((await traceOne(() => collabApi.reactMessage(1, 2, '🎉'))).body).toEqual({ emoji: '🎉' })
expect((await traceOne(() => settingsApi.set('theme', 'dark'))).body).toEqual({ key: 'theme', value: 'dark' })
expect((await traceOne(() => settingsApi.setBulk({ a: 1 }))).body).toEqual({ settings: { a: 1 } })
expect((await traceOne(() => budgetApi.togglePaid(1, 2, 4, false))).body).toEqual({ paid: false })
expect((await traceOne(() => adminApi.updateBagTracking(true))).body).toEqual({ enabled: true })
expect((await traceOne(() => adminApi.updatePermissions({ edit: 'owner' }))).body)
.toEqual({ permissions: { edit: 'owner' } })
expect((await traceOne(() => pluginsApi.saveUserSettings('koffi', { k: 'v' }))).body)
.toEqual({ config: { k: 'v' } })
})
it('FE-APISURF-025: tripsApi.archive/unarchive send the is_archived flag', async () => {
expect((await traceOne(() => tripsApi.archive(3))).body).toEqual({ is_archived: true })
expect((await traceOne(() => tripsApi.unarchive(3))).body).toEqual({ is_archived: false })
})
it('FE-APISURF-026: placesApi bulk operations merge ids with the patch', async () => {
expect((await traceOne(() => placesApi.bulkDelete(1, [5, 6]))).body).toEqual({ ids: [5, 6] })
expect((await traceOne(() => placesApi.bulkUpdate(1, [5], { category_id: null }))).body)
.toEqual({ ids: [5], category_id: null })
})
it('FE-APISURF-027: placesApi.rate deletes on null and PUTs the value otherwise', async () => {
const cleared = await traceOne(() => placesApi.rate(1, 5, null))
expect(cleared.method).toBe('DELETE')
expect(cleared.url).toBe('/api/trips/1/places/5/rating')
const set = await traceOne(() => placesApi.rate(1, 5, 4))
expect(set.method).toBe('PUT')
expect(set.url).toBe('/api/trips/1/places/5/rating')
expect(set.body).toEqual({ rating: 4 })
})
it('FE-APISURF-028: airtrailApi.import only sends connections when there are any', async () => {
expect((await traceOne(() => airtrailApi.import(1, ['f1', 'f2']))).body).toEqual({ flightIds: ['f1', 'f2'] })
expect((await traceOne(() => airtrailApi.import(1, ['f1'], []))).body).toEqual({ flightIds: ['f1'] })
expect((await traceOne(() => airtrailApi.import(1, ['f1', 'f2'], [['f1', 'f2']]))).body)
.toEqual({ flightIds: ['f1', 'f2'], connections: [['f1', 'f2']] })
})
it('FE-APISURF-029: journeyApi provider-photo calls omit optional passphrase and media types', async () => {
expect((await traceOne(() => journeyApi.addProviderPhotosToGallery(2, 'immich', ['a1']))).body)
.toEqual({ provider: 'immich', asset_ids: ['a1'] })
expect((await traceOne(() => journeyApi.addProviderPhotosToGallery(2, 'immich', ['a1'], 'secret', ['video']))).body)
.toEqual({ provider: 'immich', asset_ids: ['a1'], passphrase: 'secret', media_types: ['video'] })
expect((await traceOne(() => journeyApi.addProviderPhoto(9, 'immich', 'a1', 'cap', 'secret'))).body)
.toEqual({ provider: 'immich', asset_id: 'a1', caption: 'cap', passphrase: 'secret' })
expect((await traceOne(() => journeyApi.addProviderPhotos(9, 'immich', ['a1'], 'cap'))).body)
.toEqual({ provider: 'immich', asset_ids: ['a1'], caption: 'cap' })
expect((await traceOne(() => journeyApi.addProviderPhotos(9, 'immich', ['a1'], 'cap', 'secret', ['image', 'video']))).body)
.toEqual({ provider: 'immich', asset_ids: ['a1'], caption: 'cap', passphrase: 'secret', media_types: ['image', 'video'] })
})
it('FE-APISURF-030: adminApi.pluginActivate only sends consent when granted', async () => {
expect((await traceOne(() => adminApi.pluginActivate('koffi'))).body).toEqual({})
expect((await traceOne(() => adminApi.pluginActivate('koffi', true))).body).toEqual({ consent: true })
})
it('FE-APISURF-031: adminApi.pluginInstall spreads its options next to the id', async () => {
expect((await traceOne(() => adminApi.pluginInstall('koffi'))).body).toEqual({ id: 'koffi' })
expect((await traceOne(() => adminApi.pluginInstall('koffi', { version: '2.0.0', withDependencies: true }))).body)
.toEqual({ id: 'koffi', version: '2.0.0', withDependencies: true })
})
it('FE-APISURF-032: tripInviteApi.createLink normalises a missing expiry to null', async () => {
expect((await traceOne(() => tripInviteApi.createLink(1))).body).toEqual({ expires_in_days: null })
expect((await traceOne(() => tripInviteApi.createLink(1, 14))).body).toEqual({ expires_in_days: 14 })
})
it('FE-APISURF-033: tripsApi.copy and shareApi.createLink default to an empty body', async () => {
expect((await traceOne(() => tripsApi.copy(3))).body).toEqual({})
expect((await traceOne(() => shareApi.createLink(1))).body).toEqual({})
})
it('FE-APISURF-034: authApi.passkey.delete sends the password in the DELETE body', async () => {
const rec = await traceOne(() => authApi.passkey.delete(3, 'hunter2'))
expect(rec.method).toBe('DELETE')
expect(rec.body).toEqual({ password: 'hunter2' })
})
})
describe('client > query parameters', () => {
it('FE-APISURF-035: tripsApi.list forwards arbitrary filters as query params', async () => {
const rec = await traceOne(() => tripsApi.list({ archived: true, q: 'rome' }))
const qs = new URLSearchParams(rec.url.split('?')[1])
expect(qs.get('archived')).toBe('true')
expect(qs.get('q')).toBe('rome')
})
it('FE-APISURF-036: filesApi.list only sets the trash flag when asked', async () => {
expect((await traceOne(() => filesApi.list(1))).url).toBe('/api/trips/1/files')
expect((await traceOne(() => filesApi.list(1, true))).url).toBe('/api/trips/1/files?trash=true')
})
it('FE-APISURF-037: budgetApi.settlement adds the base currency only when given', async () => {
expect((await traceOne(() => budgetApi.settlement(1))).url).toBe('/api/trips/1/budget/settlement')
expect((await traceOne(() => budgetApi.settlement(1, 'EUR'))).url).toBe('/api/trips/1/budget/settlement?base=EUR')
})
it('FE-APISURF-038: collabApi.getMessages appends the before cursor', async () => {
expect((await traceOne(() => collabApi.getMessages(1))).url).toBe('/api/trips/1/collab/messages')
expect((await traceOne(() => collabApi.getMessages(1, '2026-01-01'))).url)
.toBe('/api/trips/1/collab/messages?before=2026-01-01')
})
it('FE-APISURF-039: adminApi.pluginBrowse only sets refresh when forced', async () => {
expect((await traceOne(() => adminApi.pluginBrowse())).url).toBe('/api/admin/plugins/registry')
expect((await traceOne(() => adminApi.pluginBrowse(true))).url).toBe('/api/admin/plugins/registry?refresh=1')
})
it('FE-APISURF-040: adminApi.auditLog and llmLocalModels pass their params through', async () => {
const audit = await traceOne(() => adminApi.auditLog({ limit: 50, offset: 100 }))
expect(new URLSearchParams(audit.url.split('?')[1]).get('limit')).toBe('50')
expect(new URLSearchParams(audit.url.split('?')[1]).get('offset')).toBe('100')
const models = await traceOne(() => adminApi.llmLocalModels('http://ollama:11434'))
expect(new URLSearchParams(models.url.split('?')[1]).get('baseUrl')).toBe('http://ollama:11434')
})
it('FE-APISURF-041: mapsApi flattens the POI bbox into the query string', async () => {
const rec = await traceOne(() => mapsApi.pois('cafe', { south: 41.8, west: 12.4, north: 42.0, east: 12.6 }, 'de'))
const qs = new URLSearchParams(rec.url.split('?')[1])
expect(qs.get('category')).toBe('cafe')
expect(qs.get('south')).toBe('41.8')
expect(qs.get('west')).toBe('12.4')
expect(qs.get('north')).toBe('42')
expect(qs.get('east')).toBe('12.6')
expect(qs.get('lang')).toBe('de')
})
it('FE-APISURF-042: weatherApi sends lat/lng plus the date or language', async () => {
const forecast = await traceOne(() => weatherApi.get(41.9, 12.5, '2026-06-01'))
const fq = new URLSearchParams(forecast.url.split('?')[1])
expect([fq.get('lat'), fq.get('lng'), fq.get('date')]).toEqual(['41.9', '12.5', '2026-06-01'])
const current = await traceOne(() => weatherApi.getCurrent(41.9, 12.5, 'de'))
expect(new URLSearchParams(current.url.split('?')[1]).get('lang')).toBe('de')
})
it('FE-APISURF-043: pluginsApi joins trip ids and defaults the activity limit', async () => {
expect((await traceOne(() => pluginsApi.tripCardContributions([1, 2, 3]))).url)
.toBe('/api/trip-card-contributions?tripIds=1,2,3')
expect((await traceOne(() => pluginsApi.myActivity())).url).toBe('/api/plugin-activity?limit=200')
expect((await traceOne(() => pluginsApi.myActivity(5))).url).toBe('/api/plugin-activity?limit=5')
})
it('FE-APISURF-044: packing/todo category assignees encode the category name', async () => {
const packing = await traceOne(() => packingApi.setCategoryAssignees(1, 'Rain gear/Wet', [4]))
expect(packing.url).toBe('/api/trips/1/packing/category-assignees/Rain%20gear%2FWet')
expect(packing.body).toEqual({ user_ids: [4] })
const todo = await traceOne(() => todoApi.setCategoryAssignees(1, 'Before & after', [5]))
expect(todo.url).toBe('/api/trips/1/todo/category-assignees/Before%20%26%20after')
expect(todo.body).toEqual({ user_ids: [5] })
})
it('FE-APISURF-045: collabApi.linkPreview URL-encodes the previewed link', async () => {
const rec = await traceOne(() => collabApi.linkPreview(1, 'https://x.test/a?b=1&c=2'))
expect(rec.url).toBe('/api/trips/1/collab/link-preview?url=https%3A%2F%2Fx.test%2Fa%3Fb%3D1%26c%3D2')
})
})
describe('client > multipart uploads', () => {
// jsdom FormData bodies deadlock inside MSW, so uploads are asserted at the
// axios boundary instead (same approach as tests/integration/api/client.test.ts).
function spyPost() {
return vi.spyOn(apiClient, 'post')
.mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse)
}
it('FE-APISURF-046: every upload opts out of the 8s global timeout', async () => {
const post = spyPost()
const fd = new FormData()
await authApi.uploadAvatar(fd)
await tripsApi.uploadCover(3, fd)
await filesApi.upload(1, fd)
await journeyApi.uploadPhotos(9, fd)
await journeyApi.uploadGalleryPhotos(2, fd)
await journeyApi.uploadGalleryVideo(2, fd)
await journeyApi.uploadCover(2, fd)
await collabApi.uploadNoteFile(1, 2, fd)
expect(post.mock.calls.map(c => c[0])).toEqual([
'/auth/avatar',
'/trips/3/cover',
'/trips/1/files',
'/journeys/entries/9/photos',
'/journeys/2/gallery/photos',
'/journeys/2/gallery/video',
'/journeys/2/cover',
'/trips/1/collab/notes/2/files',
])
for (const call of post.mock.calls) {
expect(call[1]).toBeInstanceOf(FormData)
expect(call[2]).toMatchObject({ timeout: 0 })
expect((call[2] as { headers: Record<string, string> }).headers['Content-Type']).toBe('multipart/form-data')
}
})
it('FE-APISURF-047: postMultipart forwards progress, abort signal and idempotency key', async () => {
const post = spyPost()
const onUploadProgress = vi.fn((_e: unknown) => {})
const controller = new AbortController()
await filesApi.upload(1, new FormData(), {
onUploadProgress,
signal: controller.signal,
idempotencyKey: 'fixed-key',
})
const config = post.mock.calls[0][2] as {
headers: Record<string, string>
onUploadProgress?: unknown
signal?: AbortSignal
timeout: number
}
expect(config.headers['X-Idempotency-Key']).toBe('fixed-key')
expect(config.onUploadProgress).toBe(onUploadProgress)
expect(config.signal).toBe(controller.signal)
expect(config.timeout).toBe(0)
})
it('FE-APISURF-048: placesApi.uploadImage posts the file under the image field', async () => {
const post = spyPost()
const file = new File(['bytes'], 'shot.jpg', { type: 'image/jpeg' })
await placesApi.uploadImage(1, 5, file)
expect(post.mock.calls[0][0]).toBe('/trips/1/places/5/image')
const fd = post.mock.calls[0][1] as FormData
expect((fd.get('image') as File).name).toBe('shot.jpg')
})
it('FE-APISURF-049: placesApi.importGpx only appends the flags it was given', async () => {
const post = spyPost()
const file = new File(['<gpx/>'], 'track.gpx')
await placesApi.importGpx(1, file)
expect(post.mock.calls[0][0]).toBe('/trips/1/places/import/gpx')
const bare = post.mock.calls[0][1] as FormData
expect(bare.get('importWaypoints')).toBeNull()
expect(bare.get('importRoutes')).toBeNull()
expect(bare.get('importTracks')).toBeNull()
await placesApi.importGpx(1, file, { waypoints: true, routes: false, tracks: true })
const flagged = post.mock.calls[1][1] as FormData
expect(flagged.get('importWaypoints')).toBe('true')
expect(flagged.get('importRoutes')).toBe('false')
expect(flagged.get('importTracks')).toBe('true')
})
it('FE-APISURF-050: placesApi.importMapFile appends the point/path flags', async () => {
const post = spyPost()
const file = new File(['{}'], 'map.kml')
await placesApi.importMapFile(1, file)
expect(post.mock.calls[0][0]).toBe('/trips/1/places/import/map')
expect((post.mock.calls[0][1] as FormData).get('importPoints')).toBeNull()
await placesApi.importMapFile(1, file, { points: true, paths: false })
const flagged = post.mock.calls[1][1] as FormData
expect(flagged.get('importPoints')).toBe('true')
expect(flagged.get('importPaths')).toBe('false')
})
it('FE-APISURF-051: booking import posts every file plus the extraction mode', async () => {
const post = spyPost()
const files = [new File(['a'], 'a.pdf'), new File(['b'], 'b.pdf')]
await reservationsApi.importBookingPreview(1, files, 'force-ai')
expect(post.mock.calls[0][0]).toBe('/trips/1/reservations/import/booking')
const preview = post.mock.calls[0][1] as FormData
expect(preview.getAll('files')).toHaveLength(2)
expect(preview.get('mode')).toBe('force-ai')
await reservationsApi.importBookingAsync(1, files)
expect(post.mock.calls[1][0]).toBe('/trips/1/reservations/import/booking/async')
expect((post.mock.calls[1][1] as FormData).get('mode')).toBe('no-ai')
})
it('FE-APISURF-052: adminApi.pluginUpload and backupApi.uploadRestore name their form fields', async () => {
const post = spyPost()
await adminApi.pluginUpload(new File(['zip'], 'plugin.zip'))
expect(post.mock.calls[0][0]).toBe('/admin/plugins/upload')
expect(((post.mock.calls[0][1] as FormData).get('file') as File).name).toBe('plugin.zip')
await backupApi.uploadRestore(new File(['zip'], 'backup.zip'))
expect(post.mock.calls[1][0]).toBe('/backup/upload-restore')
expect(((post.mock.calls[1][1] as FormData).get('backup') as File).name).toBe('backup.zip')
})
})
+131 -660
View File
File diff suppressed because it is too large Load Diff
-342
View File
@@ -1,342 +0,0 @@
// FE-API-COLLECTIONS-001 to FE-API-COLLECTIONS-032
//
// The Collections addon wrapper is thin, but every method encodes a URL, a verb and a
// request-body shape that the server contract depends on. These tests drive each method
// through MSW and pin the method + path + payload, plus the unwrapping of `r.data`.
import { describe, it, expect, beforeEach } from 'vitest'
import { http, HttpResponse, type JsonBodyType } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import { collectionsApi } from './collections'
import type { Collection, CollectionLabel, CollectionPlace } from '@trek/shared'
const BASE = '/api/addons/collections'
const collection: Collection = { id: 1, owner_id: 1, name: 'Tokyo', place_count: 2, is_owner: true }
const place: CollectionPlace = { id: 10, collection_id: 1, name: 'Shibuya Crossing', status: 'want' }
const label: CollectionLabel = { id: 3, collection_id: 1, name: 'Food', color: '#ef4444' }
let requestUrl = ''
let requestBody: unknown
beforeEach(() => {
requestUrl = ''
requestBody = undefined
})
/** Records url + parsed JSON body of the intercepted request, then answers with `data`. */
function record<T extends JsonBodyType>(data: T) {
return async ({ request }: { request: Request }) => {
requestUrl = request.url
const text = await request.text()
if (text) {
try {
requestBody = JSON.parse(text)
} catch {
requestBody = text
}
}
return HttpResponse.json(data)
}
}
describe('collectionsApi', () => {
it('FE-API-COLLECTIONS-001: list() unwraps the collections + incomingInvites envelope', async () => {
server.use(http.get(BASE, record({ collections: [collection], incomingInvites: [] })))
const res = await collectionsApi.list()
expect(res.collections).toEqual([collection])
expect(res.incomingInvites).toEqual([])
})
it('FE-API-COLLECTIONS-002: get() requests the list by id', async () => {
server.use(http.get(`${BASE}/:id`, record({ collection, places: [place] })))
const res = await collectionsApi.get(1)
expect(requestUrl).toContain(`${BASE}/1`)
expect(res.places).toEqual([place])
expect(res.collection.name).toBe('Tokyo')
})
it('FE-API-COLLECTIONS-003: create() posts the create payload', async () => {
server.use(http.post(BASE, record({ collection })))
const res = await collectionsApi.create({ name: 'Tokyo', color: '#111827' })
expect(requestBody).toEqual({ name: 'Tokyo', color: '#111827' })
expect(res.collection.id).toBe(1)
})
it('FE-API-COLLECTIONS-004: update() patches the list by id', async () => {
server.use(http.patch(`${BASE}/:id`, record({ collection })))
const res = await collectionsApi.update(1, { name: 'Tokyo 2026' })
expect(requestUrl).toContain(`${BASE}/1`)
expect(requestBody).toEqual({ name: 'Tokyo 2026' })
expect(res.collection).toEqual(collection)
})
it('FE-API-COLLECTIONS-005: uploadCover() posts multipart to the cover endpoint', async () => {
server.use(http.post(`${BASE}/:id/cover`, record(collection)))
const fd = new FormData()
fd.append('cover', new File(['x'], 'cover.jpg'))
const res = await collectionsApi.uploadCover(1, fd)
expect(requestUrl).toContain(`${BASE}/1/cover`)
expect(res).toEqual(collection)
})
it('FE-API-COLLECTIONS-006: remove() deletes the list', async () => {
server.use(http.delete(`${BASE}/:id`, record({ success: true })))
const res = await collectionsApi.remove(4)
expect(requestUrl).toContain(`${BASE}/4`)
expect(res).toEqual({ success: true })
})
it('FE-API-COLLECTIONS-007: reorder() posts the ordered ids', async () => {
server.use(http.post(`${BASE}/reorder`, record({ success: true })))
await collectionsApi.reorder([3, 1, 2])
expect(requestBody).toEqual({ orderedIds: [3, 1, 2] })
})
it('FE-API-COLLECTIONS-008: savePlace() posts the place payload', async () => {
server.use(http.post(`${BASE}/places`, record({ place })))
const res = await collectionsApi.savePlace({ collection_id: 1, name: 'Shibuya Crossing', force: true })
expect(requestBody).toEqual({ collection_id: 1, name: 'Shibuya Crossing', force: true })
expect(res.place).toEqual(place)
})
it('FE-API-COLLECTIONS-009: saveFromTrip() posts the provenance-only payload', async () => {
server.use(http.post(`${BASE}/places/from-trip`, record({ duplicate: true, duplicateOf: { id: 9, name: 'Shibuya' } })))
const res = await collectionsApi.saveFromTrip({ collection_id: 1, source_trip_id: 7, source_place_id: 42 })
expect(requestBody).toEqual({ collection_id: 1, source_trip_id: 7, source_place_id: 42 })
expect(res.duplicate).toBe(true)
})
it('FE-API-COLLECTIONS-010: saveFromTripMany() maps its arguments onto the bulk payload', async () => {
server.use(http.post(`${BASE}/places/from-trip-many`, record({ copied: 2, skipped: [] })))
const res = await collectionsApi.saveFromTripMany(1, 7, [11, 12], true)
expect(requestBody).toEqual({ collection_id: 1, source_trip_id: 7, source_place_ids: [11, 12], force: true })
expect(res.copied).toBe(2)
})
it('FE-API-COLLECTIONS-011: updatePlace() patches the place and returns it unwrapped', async () => {
server.use(http.patch(`${BASE}/places/:pid`, record({ ...place, notes: 'busy at night' })))
const res = await collectionsApi.updatePlace(10, { notes: 'busy at night' })
expect(requestUrl).toContain(`${BASE}/places/10`)
expect(requestBody).toEqual({ notes: 'busy at night' })
expect(res.notes).toBe('busy at night')
})
it('FE-API-COLLECTIONS-012: uploadPlaceImage() posts multipart to the place image endpoint', async () => {
server.use(http.post(`${BASE}/places/:pid/image`, record({ ...place, image_url: '/uploads/p.jpg' })))
const fd = new FormData()
fd.append('image', new File(['x'], 'p.jpg'))
const res = await collectionsApi.uploadPlaceImage(10, fd)
expect(requestUrl).toContain(`${BASE}/places/10/image`)
expect(res.image_url).toBe('/uploads/p.jpg')
})
it('FE-API-COLLECTIONS-013: setStatus() posts the status', async () => {
server.use(http.post(`${BASE}/places/:pid/status`, record({ ...place, status: 'visited' })))
const res = await collectionsApi.setStatus(10, 'visited')
expect(requestUrl).toContain(`${BASE}/places/10/status`)
expect(requestBody).toEqual({ status: 'visited' })
expect(res.status).toBe('visited')
})
it('FE-API-COLLECTIONS-014: ratePlace() PUTs a numeric rating', async () => {
server.use(http.put(`${BASE}/places/:pid/rating`, record({ ...place, rating_avg: 4 })))
const res = await collectionsApi.ratePlace(10, 4)
expect(requestUrl).toContain(`${BASE}/places/10/rating`)
expect(requestBody).toEqual({ rating: 4 })
expect(res.rating_avg).toBe(4)
})
it('FE-API-COLLECTIONS-015: ratePlace(null) DELETEs the rating instead', async () => {
let deleted = false
server.use(
http.put(`${BASE}/places/:pid/rating`, () => HttpResponse.json({ error: 'should not be called' }, { status: 500 })),
http.delete(`${BASE}/places/:pid/rating`, () => {
deleted = true
return HttpResponse.json({ ...place, rating_avg: null })
}),
)
const res = await collectionsApi.ratePlace(10, null)
expect(deleted).toBe(true)
expect(res.rating_avg).toBeNull()
})
it('FE-API-COLLECTIONS-016: deletePlace() deletes the saved place', async () => {
server.use(http.delete(`${BASE}/places/:pid`, record({ success: true })))
await collectionsApi.deletePlace(10)
expect(requestUrl).toContain(`${BASE}/places/10`)
})
it('FE-API-COLLECTIONS-017: deleteMany() posts the id list', async () => {
server.use(http.post(`${BASE}/places/delete-many`, record({ deleted: 2 })))
const res = await collectionsApi.deleteMany([10, 11])
expect(requestBody).toEqual({ ids: [10, 11] })
expect(res).toEqual({ deleted: 2 })
})
it('FE-API-COLLECTIONS-018: copyToTrip() posts the copy payload and returns the dedup report', async () => {
server.use(http.post(`${BASE}/copy-to-trip`, record({ copied: 1, skipped: [{ id: 11, name: 'Shibuya' }] })))
const res = await collectionsApi.copyToTrip({ trip_id: 7, place_ids: [10, 11] })
expect(requestBody).toEqual({ trip_id: 7, place_ids: [10, 11] })
expect(res.copied).toBe(1)
expect(res.skipped).toEqual([{ id: 11, name: 'Shibuya' }])
})
it('FE-API-COLLECTIONS-019: membership() sends the lookup as query params', async () => {
server.use(http.get(`${BASE}/membership`, record({ saved: true, lists: [{ collection_id: 1, name: 'Tokyo', place_id: 10 }] })))
const res = await collectionsApi.membership({ google_place_id: 'g1', lat: 35.6, lng: 139.7 })
const params = new URL(requestUrl).searchParams
expect(params.get('google_place_id')).toBe('g1')
expect(params.get('lat')).toBe('35.6')
expect(params.get('lng')).toBe('139.7')
expect(res.saved).toBe(true)
})
it('FE-API-COLLECTIONS-020: invite() posts collection_id, user_id and role', async () => {
server.use(http.post(`${BASE}/invite`, record({ success: true })))
await collectionsApi.invite(1, 5, 'admin')
expect(requestBody).toEqual({ collection_id: 1, user_id: 5, role: 'admin' })
})
it('FE-API-COLLECTIONS-021: setMemberRole() posts the new role', async () => {
server.use(http.post(`${BASE}/members/role`, record({ success: true })))
await collectionsApi.setMemberRole(1, 5, 'viewer')
expect(requestBody).toEqual({ collection_id: 1, user_id: 5, role: 'viewer' })
})
it('FE-API-COLLECTIONS-022: acceptInvite() posts only the collection id', async () => {
server.use(http.post(`${BASE}/invite/accept`, record({ success: true })))
await collectionsApi.acceptInvite(1)
expect(requestBody).toEqual({ collection_id: 1 })
})
it('FE-API-COLLECTIONS-023: declineInvite() posts only the collection id', async () => {
server.use(http.post(`${BASE}/invite/decline`, record({ success: true })))
await collectionsApi.declineInvite(2)
expect(requestBody).toEqual({ collection_id: 2 })
})
it('FE-API-COLLECTIONS-024: cancelInvite() posts collection_id and user_id', async () => {
server.use(http.post(`${BASE}/invite/cancel`, record({ success: true })))
await collectionsApi.cancelInvite(1, 5)
expect(requestBody).toEqual({ collection_id: 1, user_id: 5 })
})
it('FE-API-COLLECTIONS-025: leave() posts the collection id', async () => {
server.use(http.post(`${BASE}/leave`, record({ success: true })))
await collectionsApi.leave(3)
expect(requestBody).toEqual({ collection_id: 3 })
})
it('FE-API-COLLECTIONS-026: removeMember() posts collection_id and user_id', async () => {
server.use(http.post(`${BASE}/members/remove`, record({ success: true })))
await collectionsApi.removeMember(1, 9)
expect(requestBody).toEqual({ collection_id: 1, user_id: 9 })
})
it('FE-API-COLLECTIONS-027: availableUsers() reads the invitable users for a list', async () => {
server.use(http.get(`${BASE}/:id/available-users`, record({ users: [{ id: 5, username: 'bob' }] })))
const res = await collectionsApi.availableUsers(1)
expect(requestUrl).toContain(`${BASE}/1/available-users`)
expect(res.users).toEqual([{ id: 5, username: 'bob' }])
})
it('FE-API-COLLECTIONS-028: createLabel() posts collection_id, name and color', async () => {
server.use(http.post(`${BASE}/labels`, record(label)))
const res = await collectionsApi.createLabel(1, 'Food', '#ef4444')
expect(requestBody).toEqual({ collection_id: 1, name: 'Food', color: '#ef4444' })
expect(res).toEqual(label)
})
it('FE-API-COLLECTIONS-029: updateLabel() patches the label by id', async () => {
server.use(http.patch(`${BASE}/labels/:id`, record({ ...label, name: 'Eats' })))
const res = await collectionsApi.updateLabel(3, { name: 'Eats' })
expect(requestUrl).toContain(`${BASE}/labels/3`)
expect(requestBody).toEqual({ name: 'Eats' })
expect(res.name).toBe('Eats')
})
it('FE-API-COLLECTIONS-030: deleteLabel() deletes the label by id', async () => {
server.use(http.delete(`${BASE}/labels/:id`, record({ success: true })))
await collectionsApi.deleteLabel(3)
expect(requestUrl).toContain(`${BASE}/labels/3`)
})
it('FE-API-COLLECTIONS-031: assignLabels() posts label_ids and place_ids', async () => {
server.use(http.post(`${BASE}/labels/assign`, record({ changed: 2 })))
const res = await collectionsApi.assignLabels([3], [10, 11])
expect(requestBody).toEqual({ label_ids: [3], place_ids: [10, 11] })
expect(res.changed).toBe(2)
})
it('FE-API-COLLECTIONS-032: unassignLabels() posts to the unassign endpoint', async () => {
server.use(http.post(`${BASE}/labels/unassign`, record({ changed: 1 })))
const res = await collectionsApi.unassignLabels([3], [10])
expect(requestUrl).toContain(`${BASE}/labels/unassign`)
expect(requestBody).toEqual({ label_ids: [3], place_ids: [10] })
expect(res.changed).toBe(1)
})
})
-118
View File
@@ -1,118 +0,0 @@
import apiClient, { postMultipart } from './client'
import type { AxiosResponse } from 'axios'
import type {
CollectionListResponse,
CollectionDetailResponse,
CollectionSaveResult,
CollectionMembership,
CollectionCreateRequest,
CollectionUpdateRequest,
CollectionSavePlaceRequest,
CollectionSaveFromTripRequest,
CollectionPlaceUpdateRequest,
CollectionCopyToTripRequest,
CollectionInviteRequest,
CollectionRole,
CollectionInviteActionRequest,
CollectionInviteCancelRequest,
CollectionStatus,
Collection,
CollectionPlace,
CollectionLabel,
CollectionLabelCreateRequest,
CollectionLabelUpdateRequest,
} from '@trek/shared'
const ax = apiClient
const base = '/addons/collections'
/** Query for the library-wide "is this place already saved?" lookup. */
export interface MembershipQuery {
google_place_id?: string
google_ftid?: string
name?: string
lat?: number
lng?: number
}
export interface CopyToTripResult {
copied: number
skipped: { id: number; name: string }[]
}
/**
* Axios calls for the Collections addon (/api/addons/collections). Mirrors the
* vacayStore api shape — each method returns the unwrapped response body and
* uses `satisfies` on the request payloads so the shared Zod request types stay
* the single source of truth.
*/
export const collectionsApi = {
list: (): Promise<CollectionListResponse> =>
ax.get(base).then((r: AxiosResponse) => r.data),
get: (id: number): Promise<CollectionDetailResponse> =>
ax.get(`${base}/${id}`).then((r: AxiosResponse) => r.data),
create: (body: CollectionCreateRequest): Promise<{ collection: Collection }> =>
ax.post(base, body satisfies CollectionCreateRequest).then((r: AxiosResponse) => r.data),
update: (id: number, body: CollectionUpdateRequest): Promise<{ collection: Collection }> =>
ax.patch(`${base}/${id}`, body satisfies CollectionUpdateRequest).then((r: AxiosResponse) => r.data),
uploadCover: (id: number, formData: FormData): Promise<Collection> =>
postMultipart(`${base}/${id}/cover`, formData),
remove: (id: number): Promise<unknown> =>
ax.delete(`${base}/${id}`).then((r: AxiosResponse) => r.data),
reorder: (orderedIds: number[]): Promise<unknown> =>
ax.post(`${base}/reorder`, { orderedIds }).then((r: AxiosResponse) => r.data),
savePlace: (body: CollectionSavePlaceRequest): Promise<CollectionSaveResult> =>
ax.post(`${base}/places`, body satisfies CollectionSavePlaceRequest).then((r: AxiosResponse) => r.data),
saveFromTrip: (body: CollectionSaveFromTripRequest): Promise<CollectionSaveResult> =>
ax.post(`${base}/places/from-trip`, body satisfies CollectionSaveFromTripRequest).then((r: AxiosResponse) => r.data),
saveFromTripMany: (collectionId: number, tripId: number, placeIds: number[], force?: boolean): Promise<{ copied: number; skipped: { id: number; name: string }[] }> =>
ax.post(`${base}/places/from-trip-many`, { collection_id: collectionId, source_trip_id: tripId, source_place_ids: placeIds, force }).then((r: AxiosResponse) => r.data),
updatePlace: (pid: number, body: CollectionPlaceUpdateRequest): Promise<CollectionPlace> =>
ax.patch(`${base}/places/${pid}`, body satisfies CollectionPlaceUpdateRequest).then((r: AxiosResponse) => r.data),
uploadPlaceImage: (pid: number, formData: FormData): Promise<CollectionPlace> =>
postMultipart(`${base}/places/${pid}/image`, formData),
setStatus: (pid: number, status: CollectionStatus): Promise<CollectionPlace> =>
ax.post(`${base}/places/${pid}/status`, { status }).then((r: AxiosResponse) => r.data),
ratePlace: (pid: number, rating: number | null): Promise<CollectionPlace> =>
rating === null
? ax.delete(`${base}/places/${pid}/rating`).then((r: AxiosResponse) => r.data)
: ax.put(`${base}/places/${pid}/rating`, { rating }).then((r: AxiosResponse) => r.data),
deletePlace: (pid: number): Promise<unknown> =>
ax.delete(`${base}/places/${pid}`).then((r: AxiosResponse) => r.data),
deleteMany: (ids: number[]): Promise<unknown> =>
ax.post(`${base}/places/delete-many`, { ids }).then((r: AxiosResponse) => r.data),
copyToTrip: (body: CollectionCopyToTripRequest): Promise<CopyToTripResult> =>
ax.post(`${base}/copy-to-trip`, body satisfies CollectionCopyToTripRequest).then((r: AxiosResponse) => r.data),
membership: (params: MembershipQuery): Promise<CollectionMembership> =>
ax.get(`${base}/membership`, { params }).then((r: AxiosResponse) => r.data),
invite: (collectionId: number, userId: number, role?: CollectionRole): Promise<unknown> =>
ax.post(`${base}/invite`, { collection_id: collectionId, user_id: userId, role } satisfies CollectionInviteRequest).then((r: AxiosResponse) => r.data),
setMemberRole: (collectionId: number, userId: number, role: CollectionRole): Promise<unknown> =>
ax.post(`${base}/members/role`, { collection_id: collectionId, user_id: userId, role }).then((r: AxiosResponse) => r.data),
acceptInvite: (collectionId: number): Promise<unknown> =>
ax.post(`${base}/invite/accept`, { collection_id: collectionId } satisfies CollectionInviteActionRequest).then((r: AxiosResponse) => r.data),
declineInvite: (collectionId: number): Promise<unknown> =>
ax.post(`${base}/invite/decline`, { collection_id: collectionId } satisfies CollectionInviteActionRequest).then((r: AxiosResponse) => r.data),
cancelInvite: (collectionId: number, userId: number): Promise<unknown> =>
ax.post(`${base}/invite/cancel`, { collection_id: collectionId, user_id: userId } satisfies CollectionInviteCancelRequest).then((r: AxiosResponse) => r.data),
leave: (collectionId: number): Promise<unknown> =>
ax.post(`${base}/leave`, { collection_id: collectionId }).then((r: AxiosResponse) => r.data),
removeMember: (collectionId: number, userId: number): Promise<unknown> =>
ax.post(`${base}/members/remove`, { collection_id: collectionId, user_id: userId }).then((r: AxiosResponse) => r.data),
availableUsers: (id: number): Promise<{ users: { id: number; username: string }[] }> =>
ax.get(`${base}/${id}/available-users`).then((r: AxiosResponse) => r.data),
createLabel: (collectionId: number, name: string, color?: string): Promise<CollectionLabel> =>
ax.post(`${base}/labels`, { collection_id: collectionId, name, color } satisfies CollectionLabelCreateRequest).then((r: AxiosResponse) => r.data),
updateLabel: (labelId: number, body: CollectionLabelUpdateRequest): Promise<CollectionLabel> =>
ax.patch(`${base}/labels/${labelId}`, body satisfies CollectionLabelUpdateRequest).then((r: AxiosResponse) => r.data),
deleteLabel: (labelId: number): Promise<unknown> =>
ax.delete(`${base}/labels/${labelId}`).then((r: AxiosResponse) => r.data),
assignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
ax.post(`${base}/labels/assign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
unassignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
ax.post(`${base}/labels/unassign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
}
+2 -4
View File
@@ -7,7 +7,6 @@ describe('SCOPE_GROUPS', () => {
const expected = [
'trips:read', 'trips:write', 'trips:delete', 'trips:share',
'places:read', 'places:write',
'collections:read', 'collections:write',
'atlas:read', 'atlas:write',
'packing:read', 'packing:write',
'todos:read', 'todos:write',
@@ -17,7 +16,6 @@ describe('SCOPE_GROUPS', () => {
'notifications:read', 'notifications:write',
'vacay:read', 'vacay:write',
'geo:read', 'weather:read',
'journey:read', 'journey:write', 'journey:share',
]
for (const scope of expected) {
expect(SCOPE_GROUPS).toHaveProperty(scope)
@@ -34,8 +32,8 @@ describe('SCOPE_GROUPS', () => {
})
describe('ALL_SCOPES', () => {
it('FE-OAUTH-SCOPES-003: contains exactly 29 scopes', () => {
expect(ALL_SCOPES).toHaveLength(29)
it('FE-OAUTH-SCOPES-003: contains exactly 27 scopes', () => {
expect(ALL_SCOPES).toHaveLength(27)
})
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
-2
View File
@@ -20,8 +20,6 @@ export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
'trips:share': { labelKey: 'oauth.scope.trips:share.label', descriptionKey: 'oauth.scope.trips:share.description', groupKey: 'oauth.scope.group.trips' },
'places:read': { labelKey: 'oauth.scope.places:read.label', descriptionKey: 'oauth.scope.places:read.description', groupKey: 'oauth.scope.group.places' },
'places:write': { labelKey: 'oauth.scope.places:write.label', descriptionKey: 'oauth.scope.places:write.description', groupKey: 'oauth.scope.group.places' },
'collections:read': { labelKey: 'oauth.scope.collections:read.label', descriptionKey: 'oauth.scope.collections:read.description', groupKey: 'oauth.scope.group.collections' },
'collections:write': { labelKey: 'oauth.scope.collections:write.label', descriptionKey: 'oauth.scope.collections:write.description', groupKey: 'oauth.scope.group.collections' },
'atlas:read': { labelKey: 'oauth.scope.atlas:read.label', descriptionKey: 'oauth.scope.atlas:read.description', groupKey: 'oauth.scope.group.atlas' },
'atlas:write': { labelKey: 'oauth.scope.atlas:write.label', descriptionKey: 'oauth.scope.atlas:write.description', groupKey: 'oauth.scope.group.atlas' },
'packing:read': { labelKey: 'oauth.scope.packing:read.label', descriptionKey: 'oauth.scope.packing:read.description', groupKey: 'oauth.scope.group.packing' },
-75
View File
@@ -1,75 +0,0 @@
// FE-API-UPLOAD-001 to FE-API-UPLOAD-013
//
// The shared axios instance carries timeout: 8000, and axios' timeout is a whole-request
// deadline — not an idle one. Any upload whose body takes longer than 8s to push is
// aborted mid-stream and the server reports a multer "Request aborted" (#1495).
//
// The original fix added `timeout: 0` to the three cover uploads by hand, which left the
// same bug live on 7 other endpoints — including the two that accept 500 MB (documents
// and backup restore). Every multipart call now goes through postMultipart(), so this
// suite pins ALL of them, not just the covers.
import { describe, it, expect, vi, afterEach } from 'vitest'
import {
apiClient,
authApi,
tripsApi,
placesApi,
adminApi,
journeyApi,
filesApi,
reservationsApi,
collabApi,
backupApi,
} from './client'
import { collectionsApi } from './collections'
describe('every multipart upload disables the global request timeout', () => {
afterEach(() => {
vi.restoreAllMocks()
})
function spyPost() {
return vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} } as any)
}
const fd = () => new FormData()
const file = () => new File(['x'], 'f.bin')
// [id, description, invoke, expected url]
const cases: [string, string, () => Promise<unknown>, string][] = [
['FE-API-UPLOAD-001', 'authApi.uploadAvatar (5 MB)', () => authApi.uploadAvatar(fd()), '/auth/avatar'],
['FE-API-UPLOAD-002', 'tripsApi.uploadCover (20 MB)', () => tripsApi.uploadCover(7, fd()), '/trips/7/cover'],
['FE-API-UPLOAD-003', 'placesApi.importGpx (10 MB)', () => placesApi.importGpx(7, file()), '/trips/7/places/import/gpx'],
['FE-API-UPLOAD-004', 'placesApi.importMapFile (10 MB)', () => placesApi.importMapFile(7, file()), '/trips/7/places/import/map'],
['FE-API-UPLOAD-005', 'adminApi.pluginUpload (50 MB)', () => adminApi.pluginUpload(file()), '/admin/plugins/upload'],
['FE-API-UPLOAD-006', 'journeyApi.uploadCover (20 MB)', () => journeyApi.uploadCover(7, fd()), '/journeys/7/cover'],
['FE-API-UPLOAD-007', 'journeyApi.uploadPhotos (20 MB)', () => journeyApi.uploadPhotos(7, fd()), '/journeys/entries/7/photos'],
['FE-API-UPLOAD-008', 'journeyApi.uploadGalleryVideo (500 MB)', () => journeyApi.uploadGalleryVideo(7, fd()), '/journeys/7/gallery/video'],
['FE-API-UPLOAD-009', 'filesApi.upload (500 MB)', () => filesApi.upload(7, fd()), '/trips/7/files'],
['FE-API-UPLOAD-010', 'collabApi.uploadNoteFile (50 MB)', () => collabApi.uploadNoteFile(7, 3, fd()), '/trips/7/collab/notes/3/files'],
['FE-API-UPLOAD-011', 'backupApi.uploadRestore (500 MB)', () => backupApi.uploadRestore(file()), '/backup/upload-restore'],
['FE-API-UPLOAD-012', 'collectionsApi.uploadCover (20 MB)', () => collectionsApi.uploadCover(7, fd()), '/addons/collections/7/cover'],
]
for (const [id, desc, invoke, url] of cases) {
it(`${id}: ${desc} posts with timeout 0`, async () => {
const post = spyPost()
await invoke()
expect(post).toHaveBeenCalledWith(
url,
expect.any(FormData),
expect.objectContaining({ timeout: 0 }),
)
})
}
it('FE-API-UPLOAD-013: reservationsApi booking import posts with timeout 0', async () => {
const post = spyPost()
await reservationsApi.importBookingPreview(7, [file()])
expect(post).toHaveBeenCalledWith(
'/trips/7/reservations/import/booking',
expect.any(FormData),
expect.objectContaining({ timeout: 0 }),
)
})
})
-267
View File
@@ -1,267 +0,0 @@
// vi.unmock must run before the module is imported (tests/setup.ts mocks it globally)
vi.unmock('./websocket')
// FE-WSCORE-001 to FE-WSCORE-014
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import {
connect, disconnect, joinTrip, leaveTrip, getActiveTrips,
setRefetchCallback, setPreReconnectHook,
} from './websocket'
class MockWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
static instances: MockWebSocket[] = []
readyState: number = MockWebSocket.OPEN
send = vi.fn((_data: string) => {})
close = vi.fn(() => {})
onopen: (() => void) | null = null
onmessage: ((event: { data: string }) => void) | null = null
onclose: (() => void) | null = null
onerror: (() => void) | null = null
constructor(public url: string) {
MockWebSocket.instances.push(this)
}
}
function lastSocket(): MockWebSocket {
return MockWebSocket.instances[MockWebSocket.instances.length - 1]
}
const realLocation = window.location
beforeEach(() => {
vi.useFakeTimers()
MockWebSocket.instances = []
Object.defineProperty(globalThis, 'WebSocket', {
writable: true, configurable: true, value: MockWebSocket,
})
server.use(http.post('/api/auth/ws-token', () => HttpResponse.json({ token: 'ws-tok' })))
})
afterEach(() => {
disconnect()
setRefetchCallback(null)
setPreReconnectHook(null)
vi.useRealTimers()
vi.restoreAllMocks()
Object.defineProperty(window, 'location', { writable: true, configurable: true, value: realLocation })
})
/** connect() + settle the token fetch so a socket exists. */
async function openSocket(): Promise<MockWebSocket> {
connect()
await vi.advanceTimersByTimeAsync(0)
return lastSocket()
}
describe('websocket > active trips', () => {
it('FE-WSCORE-001: getActiveTrips lists the joined trips as strings', async () => {
expect(getActiveTrips()).toEqual([])
joinTrip(42)
joinTrip('7')
expect(getActiveTrips()).toEqual(['42', '7'])
disconnect()
expect(getActiveTrips()).toEqual([])
})
it('FE-WSCORE-013: join/leave still bookkeep while no socket is open', () => {
joinTrip(5)
expect(getActiveTrips()).toEqual(['5'])
leaveTrip(5)
expect(getActiveTrips()).toEqual([])
})
it('FE-WSCORE-014: a trip joined before onopen is not re-sent while the socket is closing', async () => {
joinTrip(11)
const sock = await openSocket()
sock.readyState = MockWebSocket.CLOSING
sock.onopen!()
expect(sock.send).not.toHaveBeenCalled()
})
})
describe('websocket > reconnect refetch hook', () => {
it('FE-WSCORE-002: the pre-reconnect hook is awaited before the refetch runs', async () => {
const order: string[] = []
setPreReconnectHook(async () => { order.push('flush') })
setRefetchCallback(() => { order.push('refetch') })
joinTrip(3)
const sock = await openSocket()
sock.onopen!()
await vi.advanceTimersByTimeAsync(0)
expect(order).toEqual(['flush', 'refetch'])
})
it('FE-WSCORE-003: a rejecting pre-reconnect hook still lets the refetch run', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const refetch = vi.fn((_tripId: string) => {})
setPreReconnectHook(async () => { throw new Error('queue flush failed') })
setRefetchCallback(refetch)
joinTrip(3)
const sock = await openSocket()
sock.onopen!()
await vi.advanceTimersByTimeAsync(0)
expect(refetch).toHaveBeenCalledWith('3')
expect(consoleError).toHaveBeenCalled()
})
it('FE-WSCORE-004: a throwing refetch callback is logged, not propagated', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
setRefetchCallback(() => { throw new Error('store blew up') })
joinTrip(3)
const sock = await openSocket()
expect(() => sock.onopen!()).not.toThrow()
expect(consoleError).toHaveBeenCalledWith(
'Failed to refetch trip data on reconnect:',
expect.any(Error),
)
})
it('FE-WSCORE-005: with no joined trips onopen sends nothing and skips the refetch', async () => {
const refetch = vi.fn((_tripId: string) => {})
setRefetchCallback(refetch)
const sock = await openSocket()
sock.onopen!()
expect(sock.send).not.toHaveBeenCalled()
expect(refetch).not.toHaveBeenCalled()
})
})
describe('websocket > connection lifecycle', () => {
it('FE-WSCORE-006: connect() is a no-op while a socket is still CONNECTING', async () => {
const sock = await openSocket()
sock.readyState = MockWebSocket.CONNECTING
connect()
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(1)
})
it('FE-WSCORE-007: connect() cancels a pending reconnect timer', async () => {
server.use(http.post('/api/auth/ws-token', () => new HttpResponse(null, { status: 503 })))
connect()
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(0)
// A retry is now armed; connect() must clear it and dial immediately.
server.use(http.post('/api/auth/ws-token', () => HttpResponse.json({ token: 'fresh' })))
connect()
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(1)
// The cancelled timer must not fire a second dial afterwards.
await vi.advanceTimersByTimeAsync(5000)
expect(MockWebSocket.instances).toHaveLength(1)
})
it('FE-WSCORE-008: a duplicate close does not stack a second timer or skip a backoff step', async () => {
const sock = await openSocket()
// Every further token fetch fails, so each retry attempt is countable.
let attempts = 0
server.use(http.post('/api/auth/ws-token', () => {
attempts++
return new HttpResponse(null, { status: 503 })
}))
// A browser can deliver close twice (after onerror); the second must be ignored.
sock.onclose!()
sock.onclose!()
await vi.advanceTimersByTimeAsync(1001)
await vi.advanceTimersByTimeAsync(0)
expect(attempts, 'first retry fires after the 1s delay').toBe(1)
// Backoff advanced once (1s → 2s), not twice, so the next retry lands at 2s.
await vi.advanceTimersByTimeAsync(2001)
await vi.advanceTimersByTimeAsync(0)
expect(attempts, 'second retry fires after the doubled 2s delay').toBe(2)
expect(MockWebSocket.instances).toHaveLength(1)
})
it('FE-WSCORE-009: a failing ws-token fetch schedules a retry instead of throwing', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('offline'))
connect()
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(0)
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(JSON.stringify({ token: 'back-online' }), {
status: 200, headers: { 'Content-Type': 'application/json' },
}),
)
await vi.advanceTimersByTimeAsync(1001)
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(1)
expect(lastSocket().url).toContain('token=back-online')
})
it('FE-WSCORE-010: the socket URL uses ws:// on http and wss:// on https', async () => {
const httpSock = await openSocket()
expect(httpSock.url.startsWith('ws://')).toBe(true)
disconnect()
MockWebSocket.instances = []
Object.defineProperty(window, 'location', {
writable: true, configurable: true,
value: {
protocol: 'https:',
host: 'trip.example',
origin: 'https://trip.example',
href: 'https://trip.example/dashboard',
pathname: '/dashboard',
},
})
const secure = await openSocket()
expect(secure.url).toBe('wss://trip.example/ws?token=ws-tok')
})
it('FE-WSCORE-011: disconnect() detaches onclose so no reconnect is armed', async () => {
const sock = await openSocket()
disconnect()
expect(sock.onclose).toBeNull()
expect(sock.close).toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(5000)
expect(MockWebSocket.instances).toHaveLength(1)
})
it('FE-WSCORE-012: onerror is inert — the reconnect is driven by onclose', async () => {
const sock = await openSocket()
expect(() => sock.onerror!()).not.toThrow()
expect(MockWebSocket.instances).toHaveLength(1)
sock.onclose!()
await vi.advanceTimersByTimeAsync(1001)
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(2)
})
})
-6
View File
@@ -20,12 +20,6 @@ export function getSocketId(): string | null {
return mySocketId
}
/** Trip ids the app currently has open (joined). Used to re-hydrate the active
* trip's store after the network comes back via the `online` event. */
export function getActiveTrips(): string[] {
return Array.from(activeTrips)
}
export function setRefetchCallback(fn: RefetchCallback | null): void {
refetchCallback = fn
}
-96
View File
@@ -1,96 +0,0 @@
import type { TrekWsEventName } from '@trek/shared'
/**
* Client-side handling policy for every event in the shared WS registry
* (`TREK_WS_EVENTS` in @trek/shared). Together with the tripStore lookups
* (DEXIE_WRITERS / STATE_APPLIERS in store/slices/remoteEventHandler.ts),
* these lists partition the registry exactly — the registry-parity test
* fails if a registry event is missing from all of them, or listed twice.
* A new server event therefore forces an explicit client decision (handle
* it, or add it here) instead of being dropped by a silent `default:`.
*/
/**
* Events consumed by dedicated listeners outside the tripStore reducer.
* Every entry names real handling code — if that code is removed, remove
* the entry (the event then needs a new home or an IGNORED_WS_EVENTS slot).
*/
export const HANDLED_OUTSIDE_TRIP_STORE = [
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
'collab:note:created',
'collab:note:updated',
'collab:note:deleted',
'collab:poll:created',
'collab:poll:voted',
'collab:poll:closed',
'collab:poll:deleted',
'collab:message:created',
'collab:message:reacted',
'collab:message:deleted',
// In-app notifications — hooks/useInAppNotificationListener
'notification:new',
'notification:updated',
// Collections — pages/collections/useCollections ('collections:' prefix listener)
'collections:updated',
'collections:accepted',
'collections:declined',
'collections:left',
'collections:deleted',
'collections:cancelled',
'collections:removed',
'collections:invite',
// Vacay — pages/vacay/useVacay
'vacay:update',
'vacay:settings',
'vacay:accepted',
'vacay:declined',
'vacay:cancelled',
'vacay:dissolved',
'vacay:invite',
'vacay:share',
'vacay:share-removed',
'vacay:shared-update',
// Journey — pages/journeyDetail/useJourneyDetail ('journey:' prefix listener)
'journey:trip:synced',
'journey:entry:created',
'journey:entry:updated',
'journey:entry:deleted',
'journey:entries:reordered',
'journey:contributor:changed',
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
'import:progress',
'import:done',
'import:error',
] as const satisfies readonly TrekWsEventName[]
/**
* Events the client deliberately does not act on today (state of the world
* when the registry landed — every one of these was already dropped by the
* old silent `default:` branches). Removing an entry means the event is now
* handled somewhere; ADDING an entry is a product decision that a new server
* event should have no client reaction — never add one just to silence the
* registry-parity test.
*/
export const IGNORED_WS_EVENTS = [
'assignment:participants',
'packing:reordered',
'packing:bag-created',
'packing:bag-updated',
'packing:bag-deleted',
'packing:bag-members-updated',
'packing:assignees',
'packing:template-applied',
'todo:assignees',
'budget:settlement-created',
'budget:settlement-updated',
'budget:settlement-deleted',
'reservation:positions',
// Accommodations live in page-local planner state; the client refetches
// them off trip:updated date changes, never off these events.
'accommodation:created',
'accommodation:updated',
'accommodation:deleted',
'trip:deleted',
'member:added',
'member:removed',
] as const satisfies readonly TrekWsEventName[]
@@ -1,7 +1,7 @@
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-025
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-011
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
@@ -21,45 +21,6 @@ function buildAddon(overrides = {}) {
};
}
function addonsRoute(addons: ReturnType<typeof buildAddon>[]) {
return http.get('/api/admin/addons', () => HttpResponse.json({ addons }));
}
function llmAddon(config: Record<string, unknown> = {}) {
return buildAddon({
id: 'llm_parsing',
name: 'AI Parsing',
description: 'Extract bookings from files',
icon: 'Sparkles',
type: 'integration',
enabled: true,
config,
});
}
function modelsRoute(names: string[], seen?: (string | null)[]) {
return http.get('/api/admin/llm/local/models', ({ request }) => {
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
return HttpResponse.json({ models: names.map(name => ({ name, size: 1 })) });
});
}
/** The pill toggle of a top-level addon row. */
function addonToggle(name: string): HTMLElement {
const row = screen.getByText(name).closest('.px-6.py-4') as HTMLElement;
return within(row).getByRole('button');
}
/** The pill toggle of an indented sub-row (bag tracking, collab feature, photo provider). */
function subToggle(label: string): HTMLElement {
const row = screen.getByText(label).closest('.flex.items-center.gap-4') as HTMLElement;
return within(row).getByRole('button');
}
function isOn(toggle: HTMLElement): boolean {
return toggle.style.background === 'var(--text-primary)';
}
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -268,332 +229,4 @@ describe('AddonManager', () => {
expect(() => render(<AddonManager />)).not.toThrow();
await screen.findByText('Mystery Addon');
});
it('FE-ADMIN-ADDON-012: a failing load toasts the addon error and shows the empty state', async () => {
server.use(http.get('/api/admin/addons', () => HttpResponse.error()));
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Failed to update addon');
expect(screen.getByText('No addons available')).toBeInTheDocument();
});
it('FE-ADMIN-ADDON-013: dark mode swaps the wordmark in the header', async () => {
seedStore(useSettingsStore, { settings: { dark_mode: 'dark' } });
render(<AddonManager />);
await screen.findByText('No addons available');
expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-light.svg');
});
it('FE-ADMIN-ADDON-014: photo-flavoured trip addons are hidden from the trip section', async () => {
server.use(addonsRoute([
buildAddon({ id: 'photos', name: 'Memories', icon: 'Image' }),
buildAddon({ id: 'gallery', name: 'Trip Photos', icon: 'Puzzle', description: 'Share your photo stream' }),
buildAddon({ id: 'todo', name: 'Todo List' }),
]));
render(<AddonManager />);
await screen.findByText('Todo List');
expect(screen.queryByText('Memories')).not.toBeInTheDocument();
expect(screen.queryByText('Trip Photos')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-015: provider sub-rows carry their vendor icons and toggle state', async () => {
server.use(addonsRoute([
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: true }),
buildAddon({ id: 'synologyphotos', name: 'Synology Photos', description: 'NAS photos', type: 'photo_provider', enabled: false }),
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }),
]));
render(<AddonManager />);
await screen.findByText('Immich');
// immich and synologyphotos ship a vendor glyph, unsplash does not
const immichRow = screen.getByText('Immich').closest('.flex.items-center.gap-4') as HTMLElement;
expect(immichRow.querySelector('svg')).toBeInTheDocument();
const synologyRow = screen.getByText('Synology Photos').closest('.flex.items-center.gap-4') as HTMLElement;
expect(synologyRow.querySelector('svg')).toBeInTheDocument();
const unsplashRow = screen.getByText('Unsplash').closest('.flex.items-center.gap-4') as HTMLElement;
expect(unsplashRow.querySelector('svg')).not.toBeInTheDocument();
expect(isOn(subToggle('Immich'))).toBe(true);
expect(isOn(subToggle('Unsplash'))).toBe(false);
});
it('FE-ADMIN-ADDON-016: toggling a photo provider persists it and refreshes the global addons', async () => {
const user = userEvent.setup();
let body: unknown = null;
server.use(
addonsRoute([
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: false }),
]),
http.put('/api/admin/addons/immich', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Immich');
await user.click(subToggle('Immich'));
await waitFor(() => expect(body).toEqual({ enabled: true }));
await screen.findByText('Addon updated');
expect(isOn(subToggle('Immich'))).toBe(true);
});
it('FE-ADMIN-ADDON-017: a failing photo-provider toggle rolls the sub-row back', async () => {
const user = userEvent.setup();
server.use(
addonsRoute([
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: true }),
]),
http.put('/api/admin/addons/unsplash', () => HttpResponse.error()),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Unsplash');
await user.click(subToggle('Unsplash'));
await screen.findByText('Failed to update addon');
await waitFor(() => expect(isOn(subToggle('Unsplash'))).toBe(true));
});
it('FE-ADMIN-ADDON-018: the collab sub-features render their state and report the toggled key', async () => {
const user = userEvent.setup();
const onToggleCollabFeature = vi.fn();
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
render(
<AddonManager
collabFeatures={{ chat: true, notes: false, polls: false, whatsnext: true }}
onToggleCollabFeature={onToggleCollabFeature}
/>,
);
await screen.findByText('Chat');
expect(screen.getByText('Notes')).toBeInTheDocument();
expect(screen.getByText('Polls')).toBeInTheDocument();
expect(screen.getByText("What's Next")).toBeInTheDocument();
expect(isOn(subToggle('Chat'))).toBe(true);
expect(isOn(subToggle('Notes'))).toBe(false);
await user.click(subToggle('Polls'));
expect(onToggleCollabFeature).toHaveBeenCalledWith('polls');
});
it('FE-ADMIN-ADDON-019: collab sub-features stay hidden without the handler props', async () => {
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
render(<AddonManager />);
await screen.findByText('Collab');
expect(screen.queryByText('Polls')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => {
server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }]));
render(<AddonManager />);
await screen.findByText('AI Parsing');
expect(screen.getByText('Extract bookings from files')).toBeInTheDocument();
expect(screen.queryByText('Connection')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3:8b', 'llama3:8b'], urls));
render(<AddonManager />);
await screen.findByText('Installed on the server');
await screen.findByRole('button', { name: 'llama3:8b' });
expect(urls[0]).toBe('http://localhost:11434/v1');
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
// qwen3:8b is already installed, so the recommended row offers "Use" instead of "Pull"
await user.click(screen.getByRole('button', { name: 'Use' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
});
it('FE-ADMIN-ADDON-022: an unreachable Ollama shows the error and Refresh retries', async () => {
const user = userEvent.setup();
let calls = 0;
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
http.get('/api/admin/llm/local/models', () => {
calls += 1;
return calls === 1
? HttpResponse.json({ error: 'down' }, { status: 500 })
: HttpResponse.json({ models: [] });
}),
);
render(<AddonManager />);
await screen.findByText(/Request failed with status code 500/);
await user.click(screen.getByRole('button', { name: 'Refresh' }));
await screen.findByText('No models installed yet — pull one below.');
expect(calls).toBe(2);
});
it('FE-ADMIN-ADDON-023: switching providers swaps the base URL field, the model hint and the Ollama block', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'local', apiKey: '••••••••' })]), modelsRoute([], urls));
render(<AddonManager />);
await screen.findByText('Installed on the server');
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
// A hand-typed base URL is used for the next lookup on blur
await user.type(screen.getByPlaceholderText('http://localhost:11434/v1'), 'http://ollama.lan:11434/v1');
await user.tab();
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toBeInTheDocument();
expect(screen.getByPlaceholderText('gpt-4o')).toBeInTheDocument();
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument();
expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument();
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
});
it('FE-ADMIN-ADDON-024: pulling a model streams progress and then selects it', async () => {
const user = userEvent.setup();
let pulled: unknown = null;
let modelCalls = 0;
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
http.get('/api/admin/llm/local/models', () => {
modelCalls += 1;
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3:8b', size: 1 }] });
}),
http.post('/api/admin/llm/local/pull', async ({ request }) => {
pulled = await request.json();
await delay(150);
return new HttpResponse(
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
{ headers: { 'Content-Type': 'application/x-ndjson' } },
);
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('Pulling…');
expect(screen.getByText('starting…')).toBeInTheDocument();
await screen.findByText('Model pulled');
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3:8b' });
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
});
it('FE-ADMIN-ADDON-025: a failing pull surfaces the server error and saving reports both outcomes', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true })]),
modelsRoute([]),
http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error();
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('no disk space');
expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled();
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
expect(bodies[0]).toEqual({
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true },
});
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Failed to save');
});
it('FE-ADMIN-ADDON-026: model and API key are editable and their hints follow the provider', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
modelsRoute([]),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Installed on the server');
expect(screen.getByPlaceholderText('(often not required)')).toBeInTheDocument();
await user.type(screen.getByPlaceholderText('select or pull below'), ' mistral:7b ');
await user.type(screen.getByPlaceholderText('(often not required)'), 'sk-live');
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
expect(screen.getByPlaceholderText('sk-…')).toHaveValue('sk-live');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
// The model is trimmed before it is stored, the key is sent verbatim
expect(bodies[0]).toEqual({
config: { provider: 'openai', model: 'mistral:7b', baseUrl: '', apiKey: 'sk-live', multimodal: false },
});
});
it('FE-ADMIN-ADDON-027: an error frame in the pull stream aborts the pull and is reported', async () => {
const user = userEvent.setup();
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
modelsRoute([]),
http.post('/api/admin/llm/local/pull', () => new HttpResponse(
'{"status":"pulling manifest"}\n{"error":"manifest not found"}\n',
{ headers: { 'Content-Type': 'application/x-ndjson' } },
)),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('manifest not found');
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
expect(screen.queryByText('Pulling…')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-028: blurring the base URL under a cloud provider queries no local models', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'openai' })]), modelsRoute([], urls));
render(<AddonManager />);
await screen.findByText('Connection');
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
await user.type(screen.getByPlaceholderText('https://api.openai.com/v1'), 'https://proxy.local/v1');
await user.tab();
await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument());
expect(urls).toHaveLength(0);
});
});
+41 -265
View File
@@ -4,11 +4,10 @@ import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage } from 'lucide-react'
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark,
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
}
function ImmichIcon({ size = 14 }: { size?: number }) {
@@ -159,16 +158,16 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
return (
<div className="space-y-6">
{/* Header */}
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
<div className="px-6 py-4 border-b border-edge-secondary">
<h2 className="font-semibold text-content">{t('admin.addons.title')}</h2>
<p className="text-xs mt-1 text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-6 py-4 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
</p>
</div>
{addons.length === 0 ? (
<div className="p-8 text-center text-sm text-content-faint">
<div className="p-8 text-center text-sm" style={{ color: 'var(--text-faint)' }}>
{t('admin.addons.noAddons')}
</div>
) : (
@@ -176,9 +175,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
{/* Trip Addons */}
{tripAddons.length > 0 && (
<div>
<div className="px-6 py-2.5 border-b flex items-center gap-2 bg-surface-secondary border-edge-secondary">
<Briefcase size={13} className="text-content-muted" />
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
<div className="px-6 py-2.5 border-b flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
<Briefcase size={13} style={{ color: 'var(--text-muted)' }} />
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
{t('admin.addons.type.trip')} {t('admin.addons.tripHint')}
</span>
</div>
@@ -186,14 +185,14 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
<div className="flex items-center gap-4 px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
<Luggage size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<Luggage size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium text-content-secondary">{t('admin.bagTracking.title')}</div>
<div className="text-xs mt-0.5 text-content-faint">{t('admin.bagTracking.subtitle')}</div>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('admin.bagTracking.title')}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.bagTracking.subtitle')}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`hidden sm:inline text-xs font-medium ${bagTrackingEnabled ? 'text-content' : 'text-content-faint'}`}>
<span className="hidden sm:inline text-xs font-medium" style={{ color: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button onClick={onToggleBagTracking}
@@ -206,20 +205,20 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
</div>
)}
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div className="space-y-2">
{COLLAB_SUB_FEATURES.map(feat => {
const enabled = collabFeatures[feat.key]
const Icon = feat.icon
return (
<div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
<Icon size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
<Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium text-content-secondary">{t(feat.titleKey)}</div>
<div className="text-xs mt-0.5 text-content-faint">{t(feat.subtitleKey)}</div>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t(feat.titleKey)}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t(feat.subtitleKey)}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`hidden sm:inline text-xs font-medium ${enabled ? 'text-content' : 'text-content-faint'}`}>
<span className="hidden sm:inline text-xs font-medium" style={{ color: enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button onClick={() => onToggleCollabFeature(feat.key)}
@@ -243,9 +242,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
{/* Global Addons */}
{globalAddons.length > 0 && (
<div>
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
<Globe size={13} className="text-content-muted" />
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
<Globe size={13} style={{ color: 'var(--text-muted)' }} />
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
{t('admin.addons.type.global')} {t('admin.addons.globalHint')}
</span>
</div>
@@ -254,19 +253,19 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{/* Memories providers as sub-items under Journey addon */}
{addon.id === 'journey' && providerOptions.length > 0 && (
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div className="space-y-2">
{providerOptions.map(provider => {
const ProviderIcon = PROVIDER_ICONS[provider.key]
return (
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
{ProviderIcon && <span className="text-content-faint"><ProviderIcon size={14} /></span>}
{ProviderIcon && <span style={{ color: 'var(--text-faint)' }}><ProviderIcon size={14} /></span>}
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium text-content-secondary">{provider.label}</div>
<div className="text-xs mt-0.5 text-content-faint">{provider.description}</div>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`hidden sm:inline text-xs font-medium ${provider.enabled ? 'text-content' : 'text-content-faint'}`}>
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
@@ -292,19 +291,14 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
{/* Integration Addons */}
{integrationAddons.length > 0 && (
<div>
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
<Link2 size={13} className="text-content-muted" />
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
<Link2 size={13} style={{ color: 'var(--text-muted)' }} />
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
{t('admin.addons.type.integration')} {t('admin.addons.integrationHint')}
</span>
</div>
{integrationAddons.map(addon => (
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{addon.id === 'llm_parsing' && addon.enabled && (
<LlmParsingConfig addon={addon} />
)}
</div>
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
))}
</div>
)}
@@ -315,225 +309,6 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
)
}
const MASKED = '••••••••'
const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'
/** Curated models the local extractor is tuned for, pullable via Ollama. The router drives
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [
{ id: 'qwen3:8b', label: 'Qwen3 — 8B', note: 'Recommended · best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0', recommended: true, vision: false },
]
/**
* Instance-wide AI-parsing config. When set, applies to the whole instance and
* overrides per-user config (see server llmConfig.ts). The API key is masked on
* read; an unchanged mask is treated as a no-op by the server. For the local
* provider, it also lists installed Ollama models and can pull NuExtract models.
*/
function LlmParsingConfig({ addon }: { addon: Addon }) {
const toast = useToast()
const cfg = (addon.config ?? {}) as Record<string, unknown>
const [provider, setProvider] = useState<string>((cfg.provider as string) ?? 'local')
const [model, setModel] = useState<string>((cfg.model as string) ?? '')
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '')
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '')
const [saving, setSaving] = useState(false)
// Local-provider model management.
const [installed, setInstalled] = useState<string[]>([])
const [modelsErr, setModelsErr] = useState('')
const [loadingModels, setLoadingModels] = useState(false)
const [pulling, setPulling] = useState<string | null>(null)
const [pullPct, setPullPct] = useState(0)
const [pullStatus, setPullStatus] = useState('')
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL
const isInstalled = (id: string) => installed.some(n => n === id || n.startsWith(id + ':') || n.startsWith(id))
const loadModels = async () => {
if (provider !== 'local') return
setLoadingModels(true)
setModelsErr('')
try {
const res = await adminApi.llmLocalModels(effectiveUrl)
setInstalled(res.models.map(m => m.name))
} catch (e: unknown) {
setModelsErr(e instanceof Error ? e.message : 'Could not reach the local LLM server')
setInstalled([])
} finally {
setLoadingModels(false)
}
}
// Load installed models when the local provider is active.
useEffect(() => {
if (provider === 'local') loadModels()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider])
const pull = async (id: string) => {
if (pulling) return
setPulling(id)
setPullPct(0)
setPullStatus('starting…')
try {
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
if (p.error) throw new Error(p.error)
if (p.status) setPullStatus(p.status)
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100))
})
toast.success('Model pulled')
setModel(id)
await loadModels()
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'Pull failed')
} finally {
setPulling(null)
setPullPct(0)
setPullStatus('')
}
}
const save = async () => {
setSaving(true)
try {
// Send the masked sentinel unchanged so the server keeps the stored key.
await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: baseUrl.trim(), apiKey, multimodal: cfg.multimodal === true } })
toast.success('Saved')
} catch {
toast.error('Failed to save')
} finally {
setSaving(false)
}
}
const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-3 py-2 text-sm text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
const labelCls = 'mb-1.5 block text-xs font-medium text-content-secondary'
const sectionCls = 'text-[11px] font-semibold uppercase tracking-wide text-content-faint'
const providerOptions = [
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
]
return (
<div className="border-b border-edge-secondary bg-surface-secondary py-5 pr-6 pl-[70px]">
<div className="max-w-2xl space-y-6">
<p className="text-xs text-content-faint">
Set instance-wide config (applies to all users). Leave blank to let each user configure their own provider.
</p>
{/* Connection */}
<section className="space-y-3">
<div className={sectionCls}>Connection</div>
<div>
<span className={labelCls}>Provider</span>
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
</div>
{provider !== 'anthropic' && (
<label className="block">
<span className={labelCls}>Base URL</span>
<input type="url" autoComplete="off" className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
</label>
)}
<label className="block">
<span className={labelCls}>API key</span>
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
</label>
{provider === 'anthropic' && (
<p className="text-xs text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text scanned PDFs need Anthropic.</p>
)}
</section>
{/* Model */}
<section className="space-y-3">
<div className={sectionCls}>Model</div>
<label className="block">
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
</label>
{/* Local model management (Ollama) */}
{provider === 'local' && (
<div className="space-y-3 rounded-lg border border-edge-secondary bg-surface p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-content-secondary">Installed on the server</span>
<button onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
{loadingModels ? 'Loading…' : 'Refresh'}
</button>
</div>
{modelsErr && <p className="text-xs text-rose-600">{modelsErr}</p>}
{!modelsErr && installed.length === 0 && !loadingModels && (
<p className="text-xs text-content-faint">No models installed yet pull one below.</p>
)}
{installed.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{installed.map(name => (
<button
key={name}
title={name}
onClick={() => setModel(name)}
className={`max-w-full truncate rounded-full border px-2.5 py-1 text-xs transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
>
{name}
</button>
))}
</div>
)}
<div className="border-t border-edge-secondary pt-3">
<div className="mb-2 text-xs font-medium text-content-secondary">Pull a recommended model</div>
<div className="space-y-1">
{RECOMMENDED_MODELS.map(m => {
const installedHere = isInstalled(m.id)
const isPulling = pulling === m.id
const active = model === m.id
return (
<div key={m.id} className={`flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors ${active ? 'border-edge-secondary bg-surface-secondary' : 'border-transparent'}`}>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm text-content">{m.label}</span>
{m.recommended && (
<span className="rounded-md bg-[rgba(16,185,129,0.15)] px-1.5 py-px text-[10px] font-semibold text-emerald-600">Recommended</span>
)}
</div>
<div className="text-xs text-content-faint">{m.note}</div>
{isPulling && (
<div className="mt-1.5">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
</div>
<div className="mt-0.5 text-[10px] text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
</div>
)}
</div>
{installedHere ? (
<button onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
{active ? 'Selected' : 'Use'}
</button>
) : (
<button onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
{isPulling ? 'Pulling…' : 'Pull'}
</button>
)}
</div>
)
})}
</div>
</div>
</div>
)}
</section>
<button onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
)
}
interface AddonRowProps {
addon: Addon
onToggle: (addon: Addon) => void
@@ -561,31 +336,31 @@ function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statu
const displayDescription = descriptionOverride || label.description
const enabledState = statusOverride ?? addon.enabled
return (
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95 border-edge-secondary" style={{ opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
{/* Icon */}
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 bg-surface-secondary text-content">
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
<AddonIcon name={addon.icon} size={20} />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-content">{displayName}</span>
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{displayName}</span>
{isComingSoon && (
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full text-content-faint bg-surface-tertiary">
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
Coming Soon
</span>
)}
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-surface-secondary text-content-muted">
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}>
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
</span>
</div>
<p className="text-xs mt-0.5 text-content-muted">{displayDescription}</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{displayDescription}</p>
</div>
{/* Toggle */}
<div className="flex items-center gap-2 shrink-0">
<span className={`hidden sm:inline text-xs font-medium ${(enabledState && !isComingSoon) ? 'text-content' : 'text-content-faint'}`}>
<span className="hidden sm:inline text-xs font-medium" style={{ color: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
{!hideToggle && (
@@ -596,8 +371,9 @@ function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statu
style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
>
<span
className="inline-block h-4 w-4 transform rounded-full transition-transform bg-surface-card"
className="inline-block h-4 w-4 transform rounded-full transition-transform"
style={{
background: 'var(--bg-card)',
transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
}}
/>
@@ -83,14 +83,14 @@ export default function AdminMcpTokensPanel() {
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-content">{t('admin.mcpTokens.title')}</h2>
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.title')}</h2>
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
</div>
{/* OAuth Sessions */}
<div>
<h3 className="text-sm font-semibold mb-2 text-content-secondary">{t('admin.oauthSessions.sectionTitle')}</h3>
<div className="rounded-xl border overflow-hidden border-edge bg-surface-card">
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.sectionTitle')}</h3>
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
{sessionsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
@@ -102,8 +102,8 @@ export default function AdminMcpTokensPanel() {
</div>
) : (
<>
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 px-4 py-2.5 text-xs font-medium border-b border-edge bg-surface-secondary"
style={{ color: 'var(--text-tertiary)' }}>
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 px-4 py-2.5 text-xs font-medium border-b"
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
<span>{t('admin.oauthSessions.clientName')}</span>
<span>{t('admin.oauthSessions.owner')}</span>
<span className="text-right">{t('admin.oauthSessions.created')}</span>
@@ -115,31 +115,34 @@ export default function AdminMcpTokensPanel() {
const hidden = session.scopes.length - SCOPES_PREVIEW
return (
<div key={session.id}
className={`grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 px-4 py-3 ${i < sessions.length - 1 ? 'border-b border-edge' : ''}`}>
className="grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 px-4 py-3"
style={{ borderBottom: i < sessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="min-w-0">
<p className="text-sm font-medium truncate text-content">{session.client_name}</p>
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{session.client_name}</p>
<div className="flex flex-wrap gap-1 mt-1.5">
{visible.map(scope => (
<span key={scope} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono bg-surface-secondary border border-edge"
style={{ color: 'var(--text-tertiary)' }}>
<span key={scope} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>
{scope}
</span>
))}
{!expanded && hidden > 0 && (
<button onClick={() => toggleScopes(session.id)}
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
+{hidden} more
</button>
)}
{expanded && hidden > 0 && (
<button onClick={() => toggleScopes(session.id)}
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
show less
</button>
)}
</div>
</div>
<div className="flex items-center gap-1.5 text-sm pt-0.5 text-content-secondary">
<div className="flex items-center gap-1.5 text-sm pt-0.5" style={{ color: 'var(--text-secondary)' }}>
<User className="w-3.5 h-3.5 flex-shrink-0" />
<span className="whitespace-nowrap">{session.username}</span>
</div>
@@ -161,8 +164,8 @@ export default function AdminMcpTokensPanel() {
{/* MCP Tokens */}
<div>
<h3 className="text-sm font-semibold mb-2 text-content-secondary">{t('admin.mcpTokens.sectionTitle')}</h3>
<div className="rounded-xl border overflow-hidden border-edge bg-surface-card">
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.sectionTitle')}</h3>
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
{tokensLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
@@ -174,8 +177,8 @@ export default function AdminMcpTokensPanel() {
</div>
) : (
<>
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b border-edge bg-surface-secondary"
style={{ color: 'var(--text-tertiary)' }}>
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
<span>{t('admin.mcpTokens.tokenName')}</span>
<span>{t('admin.mcpTokens.owner')}</span>
<span className="text-right">{t('admin.mcpTokens.created')}</span>
@@ -184,12 +187,13 @@ export default function AdminMcpTokensPanel() {
</div>
{tokens.map((token, i) => (
<div key={token.id}
className={`grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3 ${i < tokens.length - 1 ? 'border-b border-edge' : ''}`}>
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="min-w-0">
<p className="text-sm font-medium truncate text-content">{token.name}</p>
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
</div>
<div className="flex items-center gap-1.5 text-sm text-content-secondary">
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
<User className="w-3.5 h-3.5 flex-shrink-0" />
<span className="whitespace-nowrap">{token.username}</span>
</div>
@@ -213,14 +217,14 @@ export default function AdminMcpTokensPanel() {
{/* Revoke OAuth session modal */}
{revokeConfirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
<h3 className="text-base font-semibold text-content">{t('admin.oauthSessions.revokeTitle')}</h3>
<p className="text-sm text-content-secondary">{t('admin.oauthSessions.revokeMessage')}</p>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.oauthSessions.revokeTitle')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.revokeMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setRevokeConfirmId(null)}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={() => handleRevoke(revokeConfirmId)}
@@ -234,14 +238,14 @@ export default function AdminMcpTokensPanel() {
{/* Delete MCP token modal */}
{deleteConfirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
<h3 className="text-base font-semibold text-content">{t('admin.mcpTokens.deleteTitle')}</h3>
<p className="text-sm text-content-secondary">{t('admin.mcpTokens.deleteMessage')}</p>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.deleteTitle')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.deleteMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setDeleteConfirmId(null)}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={() => handleDelete(deleteConfirmId)}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+24 -22
View File
@@ -100,53 +100,54 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-lg m-0 flex items-center gap-2 text-content">
<h2 className="font-semibold text-lg m-0 flex items-center gap-2" style={{ color: 'var(--text-primary)' }}>
<ClipboardList size={20} />
{t('admin.tabs.audit')}
</h2>
<p className="text-sm m-0 mt-1 text-content-muted">{t('admin.audit.subtitle')}</p>
<p className="text-sm m-0 mt-1" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.subtitle')}</p>
</div>
<button
type="button"
disabled={loading}
onClick={() => loadFirstPage()}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50 border-edge text-content bg-surface-card"
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-primary)', background: 'var(--bg-card)' }}
>
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
{t('admin.audit.refresh')}
</button>
</div>
<p className="text-xs m-0 text-content-faint">
<p className="text-xs m-0" style={{ color: 'var(--text-faint)' }}>
{t('admin.audit.showing', { count: entries.length, total })}
</p>
{loading && entries.length === 0 ? (
<div className="py-12 text-center text-sm text-content-muted">{t('common.loading')}</div>
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('common.loading')}</div>
) : entries.length === 0 ? (
<div className="py-12 text-center text-sm text-content-muted">{t('admin.audit.empty')}</div>
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.empty')}</div>
) : (
<div className="rounded-xl border overflow-x-auto border-edge bg-surface-card">
<div className="rounded-xl border overflow-x-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
<table className="w-full text-sm border-collapse min-w-[720px]">
<thead>
<tr className="border-b text-left border-edge-secondary">
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.time')}</th>
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.user')}</th>
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.action')}</th>
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.resource')}</th>
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.ip')}</th>
<th className="p-3 font-semibold text-content-secondary">{t('admin.audit.col.details')}</th>
<tr className="border-b text-left" style={{ borderColor: 'var(--border-secondary)' }}>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.time')}</th>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.user')}</th>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.action')}</th>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.resource')}</th>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.ip')}</th>
<th className="p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.details')}</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.id} className="border-b align-top border-edge-secondary">
<td className="p-3 whitespace-nowrap font-mono text-xs text-content">{fmtTime(e.created_at)}</td>
<td className="p-3 text-content">{userLabel(e)}</td>
<td className="p-3 font-mono text-xs text-content">{e.action}</td>
<td className="p-3 font-mono text-xs break-all max-w-[140px] text-content-muted">{e.resource || '—'}</td>
<td className="p-3 font-mono text-xs whitespace-nowrap text-content-muted">{e.ip || '—'}</td>
<td className="p-3 font-mono text-xs break-all max-w-[280px] text-content-faint">{fmtDetails(e.details)}</td>
<tr key={e.id} className="border-b align-top" style={{ borderColor: 'var(--border-secondary)' }}>
<td className="p-3 whitespace-nowrap font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{fmtTime(e.created_at)}</td>
<td className="p-3" style={{ color: 'var(--text-primary)' }}>{userLabel(e)}</td>
<td className="p-3 font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{e.action}</td>
<td className="p-3 font-mono text-xs break-all max-w-[140px]" style={{ color: 'var(--text-muted)' }}>{e.resource || '—'}</td>
<td className="p-3 font-mono text-xs whitespace-nowrap" style={{ color: 'var(--text-muted)' }}>{e.ip || '—'}</td>
<td className="p-3 font-mono text-xs break-all max-w-[280px]" style={{ color: 'var(--text-faint)' }}>{fmtDetails(e.details)}</td>
</tr>
))}
</tbody>
@@ -159,7 +160,8 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
type="button"
disabled={loading}
onClick={() => loadMore()}
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50 text-content-secondary"
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50"
style={{ color: 'var(--text-secondary)' }}
>
{t('admin.audit.loadMore')}
</button>
@@ -310,292 +310,4 @@ describe('BackupPanel', () => {
expect(screen.getByRole('button', { name: /^save$/i })).not.toBeDisabled()
})
})
// BKP-015: List request fails
it('FE-ADMIN-BKP-015: a failing list request toasts and keeps the empty state', async () => {
server.use(http.get('/api/backup/list', () => HttpResponse.error()))
render(<><ToastContainer /><BackupPanel /></>)
expect(await screen.findByText('Failed to load backups')).toBeInTheDocument()
expect(screen.getByText('No backups yet')).toBeInTheDocument()
})
// BKP-016: Create fails
it('FE-ADMIN-BKP-016: a failing create toasts the error and re-enables the button', async () => {
const user = userEvent.setup()
server.use(http.post('/api/backup/create', () => HttpResponse.error()))
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('backup-2025-01-15.zip')
await user.click(screen.getByTitle('Create Backup'))
expect(await screen.findByText('Failed to create backup')).toBeInTheDocument()
await waitFor(() => expect(screen.getByTitle('Create Backup')).toBeEnabled())
})
// BKP-017: Restore fails
it('FE-ADMIN-BKP-017: a failing restore surfaces the server message and clears the spinner', async () => {
const user = userEvent.setup()
server.use(
http.post('/api/backup/restore/:filename', () =>
HttpResponse.json({ error: 'archive is corrupt' }, { status: 400 }),
),
)
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('backup-2025-01-15.zip')
await user.click(screen.getAllByText('Restore')[0])
await user.click(await screen.findByText('Yes, restore'))
expect(await screen.findByText('archive is corrupt')).toBeInTheDocument()
await waitFor(() => expect(screen.getAllByText('Restore')[0].closest('button')).toBeEnabled())
})
// BKP-018: Upload & restore happy path
it('FE-ADMIN-BKP-018: picking a file opens the modal and uploads it on confirm', async () => {
const user = userEvent.setup()
let uploaded = false
server.use(
http.post('/api/backup/upload-restore', () => {
uploaded = true
return HttpResponse.json({ success: true })
}),
)
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('backup-2025-01-15.zip')
const reloadMock = vi.fn()
vi.stubGlobal('location', { ...window.location, reload: reloadMock })
const input = document.querySelector('input[type="file"]') as HTMLInputElement
await user.upload(input, new File(['zip'], 'restore-me.zip', { type: 'application/zip' }))
expect(await screen.findByText('Restore Backup?')).toBeInTheDocument()
expect(screen.getByText('restore-me.zip')).toBeInTheDocument()
// The picked file is cleared from the input so the same file can be chosen again
expect(input.value).toBe('')
await user.click(screen.getByText('Yes, restore'))
await waitFor(() => expect(uploaded).toBe(true))
expect(await screen.findByText('Backup restored. Page will reload…')).toBeInTheDocument()
vi.unstubAllGlobals()
})
// BKP-019: Upload & restore failure
it('FE-ADMIN-BKP-019: a failing upload restore toasts and re-enables the upload button', async () => {
const user = userEvent.setup()
server.use(
http.post('/api/backup/upload-restore', () => HttpResponse.json({ error: 'not a backup' }, { status: 400 })),
)
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('backup-2025-01-15.zip')
const input = document.querySelector('input[type="file"]') as HTMLInputElement
await user.upload(input, new File(['zip'], 'broken.zip', { type: 'application/zip' }))
await user.click(await screen.findByText('Yes, restore'))
expect(await screen.findByText('not a backup')).toBeInTheDocument()
await waitFor(() => expect(screen.getByTitle('Upload Backup')).toBeEnabled())
})
// BKP-020: Upload button forwards the click to the hidden file input
it('FE-ADMIN-BKP-020: the Upload button opens the hidden file picker', async () => {
const user = userEvent.setup()
render(<BackupPanel />)
await screen.findByText('backup-2025-01-15.zip')
const input = document.querySelector('input[type="file"]') as HTMLInputElement
const clickSpy = vi.spyOn(input, 'click').mockImplementation(() => {})
await user.click(screen.getByTitle('Upload Backup'))
expect(clickSpy).toHaveBeenCalled()
})
// BKP-021: Delete declined / failing
it('FE-ADMIN-BKP-021: declining the confirm keeps the backup, a failing delete toasts', async () => {
const user = userEvent.setup()
let deleteCalls = 0
server.use(
http.delete('/api/backup/:filename', () => {
deleteCalls += 1
return HttpResponse.json({ error: 'file is locked' }, { status: 500 })
}),
)
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('backup-2025-01-15.zip')
const trashBtn = Array.from(document.querySelectorAll('button')).find(
b => b.querySelector('svg.lucide-trash2'),
) as HTMLElement
await user.click(trashBtn)
expect(deleteCalls).toBe(0)
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
confirmSpy.mockReturnValue(true)
await user.click(trashBtn)
expect(await screen.findByText('Failed to delete')).toBeInTheDocument()
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
// BKP-022: Auto settings save fails
it('FE-ADMIN-BKP-022: a failing auto-settings save toasts and keeps the form dirty', async () => {
const user = userEvent.setup()
server.use(http.put('/api/backup/auto-settings', () => HttpResponse.error()))
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('Enable auto-backup')
await user.click(getToggleButton())
await user.click(screen.getByRole('button', { name: /^save$/i }))
expect(await screen.findByText('Failed to save settings')).toBeInTheDocument()
await waitFor(() => expect(screen.getByRole('button', { name: /^save$/i })).toBeEnabled())
})
// BKP-023: Size/date fallbacks
it('FE-ADMIN-BKP-023: missing size and date render as a dash, kilobytes are formatted', async () => {
server.use(
http.get('/api/backup/list', () =>
HttpResponse.json({
backups: [
{ filename: 'empty.zip', created_at: null, size: 0 },
{ filename: 'small.zip', created_at: '2025-03-01T08:00:00Z', size: 5120 },
],
}),
),
)
render(<BackupPanel />)
await screen.findByText('empty.zip')
expect(screen.getAllByText('-')).toHaveLength(2)
expect(screen.getByText('5.0 KB')).toBeInTheDocument()
})
// BKP-024: Invalid server timezone
it('FE-ADMIN-BKP-024: an unusable server timezone falls back to the raw timestamp', async () => {
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'Not/AZone',
}),
),
)
render(<BackupPanel />)
await screen.findByText('backup-2025-01-15.zip')
await waitFor(() => expect(screen.getByText('2025-01-15T10:00:00Z')).toBeInTheDocument())
})
// BKP-025: 12h hour picker
it('FE-ADMIN-BKP-025: the hour picker uses AM/PM labels for 12h users and stores the pick', async () => {
const user = userEvent.setup()
seedStore(useSettingsStore, { settings: { time_format: '12h' } } as any)
let saved: Record<string, unknown> | null = null
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 0, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
}),
),
http.put('/api/backup/auto-settings', async ({ request }) => {
saved = await request.json() as Record<string, unknown>
return HttpResponse.json({ settings: saved })
}),
)
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('Run at hour')
expect(screen.getByText('Server local time (12h format) (Timezone: UTC)')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '12:00 AM' }))
await user.click(await screen.findByRole('button', { name: '2:00 PM' }))
await user.click(screen.getByRole('button', { name: /^save$/i }))
await waitFor(() => expect(saved).toMatchObject({ hour: 14 }))
})
// BKP-026: Monthly interval
it('FE-ADMIN-BKP-026: the monthly interval offers a day-of-month picker', async () => {
const user = userEvent.setup()
let saved: Record<string, unknown> | null = null
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'monthly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: '',
}),
),
http.put('/api/backup/auto-settings', async ({ request }) => {
saved = await request.json() as Record<string, unknown>
return HttpResponse.json({ settings: saved })
}),
)
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('Day of month')
// No timezone from the server → the hint carries no timezone suffix
expect(screen.getByText('Server local time (24h format)')).toBeInTheDocument()
expect(screen.queryByText('Sun')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '1' }))
await user.click(await screen.findByRole('button', { name: '15' }))
await user.click(screen.getByRole('button', { name: /^save$/i }))
await waitFor(() => expect(saved).toMatchObject({ day_of_month: 15 }))
})
// BKP-027: Day of week + retention
it('FE-ADMIN-BKP-027: day-of-week and retention picks are stored together', async () => {
const user = userEvent.setup()
let saved: Record<string, unknown> | null = null
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'weekly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
}),
),
http.put('/api/backup/auto-settings', async ({ request }) => {
saved = await request.json() as Record<string, unknown>
return HttpResponse.json({ settings: saved })
}),
)
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('Day of week')
await user.click(screen.getByText('Fri'))
await user.click(screen.getByText('Keep forever'))
await user.click(screen.getByRole('button', { name: /^save$/i }))
await waitFor(() => expect(saved).toMatchObject({ day_of_week: 5, keep_days: 0 }))
})
// BKP-028: Download failure
it('FE-ADMIN-BKP-028: a failing download toasts the download error', async () => {
const user = userEvent.setup()
server.use(http.get('/api/backup/download/:filename', () => HttpResponse.error()))
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('backup-2025-01-15.zip')
await user.click(screen.getByText('Download'))
expect(await screen.findByText('Download failed')).toBeInTheDocument()
})
// BKP-029: Confirm button hover styling
it('FE-ADMIN-BKP-029: the destructive confirm button darkens on hover', async () => {
const user = userEvent.setup()
render(<BackupPanel />)
await screen.findByText('backup-2025-01-15.zip')
await user.click(screen.getAllByText('Restore')[0])
const confirmBtn = await screen.findByText('Yes, restore')
fireEvent.mouseEnter(confirmBtn)
expect(confirmBtn.style.background).toBe('rgb(185, 28, 28)')
fireEvent.mouseLeave(confirmBtn)
expect(confirmBtn.style.background).toBe('rgb(220, 38, 38)')
})
})
+15 -17
View File
@@ -186,8 +186,8 @@ export default function BackupPanel() {
<div className="flex items-center gap-3">
<HardDrive className="w-5 h-5 text-gray-400" />
<div>
<h2 className="font-semibold text-content">{t('backup.title')}</h2>
<p className="text-xs mt-1 text-content-muted">{t('backup.subtitle')}</p>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.subtitle')}</p>
</div>
</div>
<div className="flex items-center gap-2">
@@ -310,8 +310,8 @@ export default function BackupPanel() {
<div className="flex items-center gap-3 mb-6">
<Clock className="w-5 h-5 text-gray-400" />
<div>
<h2 className="font-semibold text-content">{t('backup.auto.title')}</h2>
<p className="text-xs mt-1 text-content-muted">{t('backup.auto.subtitle')}</p>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.auto.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.auto.subtitle')}</p>
</div>
</div>
@@ -360,7 +360,7 @@ export default function BackupPanel() {
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
<CustomSelect
value={String(autoSettings.hour)}
onChange={v => handleAutoSettingsChange('hour', parseInt(String(v), 10))}
onChange={v => handleAutoSettingsChange('hour', parseInt(v, 10))}
size="sm"
options={HOURS.map(h => {
let label: string
@@ -408,7 +408,7 @@ export default function BackupPanel() {
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
<CustomSelect
value={String(autoSettings.day_of_month)}
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(String(v), 10))}
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(v, 10))}
size="sm"
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
/>
@@ -458,8 +458,7 @@ export default function BackupPanel() {
{/* Restore Warning Modal */}
{restoreConfirm && (
<div
className="bg-[rgba(0,0,0,0.5)]"
style={{ position: 'fixed', inset: 0, zIndex: 9999, backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={() => setRestoreConfirm(null)}
>
<div
@@ -469,14 +468,14 @@ export default function BackupPanel() {
>
{/* Red header */}
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
<div className="bg-[rgba(255,255,255,0.2)]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<AlertTriangle size={20} className="text-white" />
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<AlertTriangle size={20} style={{ color: 'white' }} />
</div>
<div>
<h3 className="text-white" style={{ margin: 0, fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700 }}>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>
{t('backup.restoreConfirmTitle')}
</h3>
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
{restoreConfirm.filename}
</p>
</div>
@@ -484,11 +483,11 @@ export default function BackupPanel() {
{/* Body */}
<div style={{ padding: '20px 24px' }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, margin: 0 }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
{t('backup.restoreWarning')}
</p>
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.5 }}
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
>
{t('backup.restoreTip')}
@@ -500,14 +499,13 @@ export default function BackupPanel() {
<button
onClick={() => setRestoreConfirm(null)}
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.cancel')}
</button>
<button
onClick={executeRestore}
className="bg-[#dc2626] text-white"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit', background: '#dc2626', color: 'white' }}
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
>
@@ -1,5 +1,5 @@
// FE-COMP-CAT-001 to FE-COMP-CAT-020
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
// FE-COMP-CAT-001 to FE-COMP-CAT-012
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -156,148 +156,4 @@ describe('CategoryManager', () => {
await user.click(screen.getByText('Cancel'));
expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument();
});
it('FE-COMP-CAT-013: a failing list request toasts and falls back to the empty state', async () => {
server.use(http.get('/api/categories', () => HttpResponse.error()));
render(<><ToastContainer /><CategoryManager /></>);
expect(await screen.findByText('Failed to load categories')).toBeInTheDocument();
expect(screen.getByText('No categories yet')).toBeInTheDocument();
});
it('FE-COMP-CAT-014: editing a category sends a PUT and replaces the row', async () => {
const user = userEvent.setup();
let body: Record<string, unknown> | null = null;
server.use(
http.get('/api/categories', () =>
HttpResponse.json({ categories: [buildCategory({ id: 5, name: 'Hotels', color: '#6366f1', icon: 'MapPin' })] })
),
http.put('/api/categories/5', async ({ request }) => {
body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ category: buildCategory({ id: 5, name: 'Lodging', color: '#ef4444', icon: 'BedDouble' }) });
}),
);
render(<><ToastContainer /><CategoryManager /></>);
await screen.findByText('Hotels');
await user.click(screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'))[0]);
const nameInput = screen.getByDisplayValue('Hotels');
await user.clear(nameInput);
await user.type(nameInput, 'Lodging');
await user.click(screen.getByTitle('Hotel'));
await user.click(screen.getByText('Update'));
expect(await screen.findByText('Category updated')).toBeInTheDocument();
expect(body).toEqual({ name: 'Lodging', color: '#6366f1', icon: 'BedDouble' });
expect(screen.getByText('Lodging')).toBeInTheDocument();
});
it('FE-COMP-CAT-015: a failing save surfaces the server message', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/categories', () => HttpResponse.json({ error: 'name already taken' }, { status: 409 })),
);
render(<><ToastContainer /><CategoryManager /></>);
await screen.findByText('New Category');
await user.click(screen.getByText('New Category'));
await user.type(screen.getByPlaceholderText('Category name'), 'Parks');
await user.click(screen.getByText('Create'));
expect(await screen.findByText('name already taken')).toBeInTheDocument();
// The form stays open so the name can be corrected
expect(screen.getByDisplayValue('Parks')).toBeInTheDocument();
});
it('FE-COMP-CAT-016: declining the delete confirm keeps the category', async () => {
const user = userEvent.setup();
let deleteCalled = false;
server.use(
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
http.delete('/api/categories/9', () => { deleteCalled = true; return HttpResponse.json({ success: true }); }),
);
vi.spyOn(window, 'confirm').mockReturnValue(false);
render(<CategoryManager />);
await screen.findByText('Parks');
const actionBtns = screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'));
await user.click(actionBtns[1]);
expect(deleteCalled).toBe(false);
expect(screen.getByText('Parks')).toBeInTheDocument();
vi.restoreAllMocks();
});
it('FE-COMP-CAT-017: a failing delete toasts and keeps the row', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
http.delete('/api/categories/9', () => HttpResponse.json({ error: 'category in use' }, { status: 409 })),
);
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<><ToastContainer /><CategoryManager /></>);
await screen.findByText('Parks');
const actionBtns = screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'));
await user.click(actionBtns[1]);
expect(await screen.findByText('category in use')).toBeInTheDocument();
expect(screen.getByText('Parks')).toBeInTheDocument();
vi.restoreAllMocks();
});
it('FE-COMP-CAT-018: picking an icon and a preset colour updates the live preview', async () => {
const user = userEvent.setup();
render(<CategoryManager />);
await screen.findByText('New Category');
await user.click(screen.getByText('New Category'));
// Empty name → the preview falls back to the generic label
expect(screen.getByText('Category')).toBeInTheDocument();
await user.type(screen.getByPlaceholderText('Category name'), 'Beach day');
await user.click(screen.getByTitle('Beach'));
const preview = screen.getByText('Beach day');
expect(preview).toHaveStyle({ color: '#6366f1' });
await user.click(document.querySelectorAll('button[style*="background-color: rgb(239, 68, 68)"]')[0]);
expect(screen.getByText('Beach day')).toHaveStyle({ color: '#ef4444' });
});
it('FE-COMP-CAT-019: the custom colour swatch opens the native picker and adopts its value', async () => {
const user = userEvent.setup();
render(<CategoryManager />);
await screen.findByText('New Category');
await user.click(screen.getByText('New Category'));
const colorInput = document.querySelector('input[type="color"]') as HTMLInputElement;
const clickSpy = vi.spyOn(colorInput, 'click').mockImplementation(() => {});
await user.click(screen.getByTitle('Choose custom color'));
expect(clickSpy).toHaveBeenCalled();
fireEvent.change(colorInput, { target: { value: '#123456' } });
await waitFor(() => expect(screen.getByText('Category')).toHaveStyle({ color: '#123456' }));
// A non-preset colour fills the custom swatch instead of showing the pipette
expect(screen.getByTitle('Choose custom color')).toHaveStyle({ backgroundColor: '#123456' });
vi.restoreAllMocks();
});
it('FE-COMP-CAT-020: starting an edit closes the create form', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 3, name: 'Hotels' })] })),
);
render(<CategoryManager />);
await screen.findByText('Hotels');
await user.click(screen.getByText('New Category'));
expect(screen.getByPlaceholderText('Category name')).toHaveValue('');
const row = screen.getByText('Hotels').closest('.p-3') as HTMLElement;
await user.click(within(row).getAllByRole('button')[0]);
// Only the inline edit form remains, pre-filled with the row's name
expect(screen.getAllByPlaceholderText('Category name')).toHaveLength(1);
expect(screen.getByDisplayValue('Hotels')).toBeInTheDocument();
});
});
@@ -56,8 +56,8 @@ export default function CategoryManager() {
setEditingId(null)
}
// The Save button carries disabled={… || !form.name.trim()}, so the name is set here.
const handleSave = async () => {
if (!form.name.trim()) { toast.error(t('categories.toast.nameRequired')); return }
setIsSaving(true)
try {
if (editingId) {
@@ -191,8 +191,8 @@ export default function CategoryManager() {
<div className="bg-white rounded-2xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="font-semibold text-content">{t('categories.title')}</h2>
<p className="text-xs mt-1 text-content-muted">{t('categories.subtitle')}</p>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('categories.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('categories.subtitle')}</p>
</div>
<button onClick={handleStartCreate}
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
@@ -1,422 +0,0 @@
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-025
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildAdmin } from '../../../tests/helpers/factories';
import { useAuthStore } from '../../store/authStore';
import { ToastContainer } from '../shared/Toast';
import DefaultUserSettingsTab from './DefaultUserSettingsTab';
// The tile preview would pull Leaflet into jsdom; the panel only needs it to render.
vi.mock('../Map/MapView', () => ({
MapView: ({ tileUrl }: { tileUrl?: string }) => <div data-testid="map-preview" data-tile={tileUrl} />,
}));
const MAPBOX_STANDARD = 'mapbox://styles/mapbox/standard';
const MAPBOX_DARK = 'mapbox://styles/mapbox/dark-v11';
const MAPBOX_NAV_NIGHT = 'mapbox://styles/mapbox/navigation-night-v1';
const OFM_LIBERTY = 'https://tiles.openfreemap.org/styles/liberty';
const TILE_PLACEHOLDER = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
/** Stateful stand-in for the admin defaults endpoint: PUT merges, null deletes. */
function stubDefaults(initial: Record<string, unknown> = {}) {
const state: Record<string, unknown> = { ...initial };
const puts: Record<string, unknown>[] = [];
server.use(
http.get('/api/admin/default-user-settings', () => HttpResponse.json(state)),
http.put('/api/admin/default-user-settings', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
puts.push(body);
for (const [key, value] of Object.entries(body)) {
if (value === null) delete state[key];
else state[key] = value;
}
return HttpResponse.json({ ...state });
}),
);
return { puts, state };
}
function withToast() {
return render(<><ToastContainer /><DefaultUserSettingsTab /></>);
}
/** The selected option button is the one drawn with the strong border token. */
function isActive(button: HTMLElement): boolean {
return (button.style.border || '').includes('var(--text-primary)');
}
/**
* The reset link sits inside the field's own <label>; because a button is a labelable
* element the wrapping label becomes its accessible name, so it is queried positionally.
*/
function resetLink(label: string): HTMLElement {
const el = screen.getAllByText(label).find(node => node.tagName === 'LABEL');
if (!el) throw new Error(`no label found for ${label}`);
return within(el).getByRole('button');
}
function hasResetLink(label: string): boolean {
const el = screen.getAllByText(label).find(node => node.tagName === 'LABEL');
return !!el && within(el).queryByRole('button') !== null;
}
/** Opens a CustomSelect by its trigger label and picks an option from the portal. */
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string) {
await user.click(screen.getByRole('button', { name: trigger }));
const choices = await screen.findAllByRole('button', { name: option });
await user.click(choices[choices.length - 1]);
}
describe('DefaultUserSettingsTab', () => {
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
stubDefaults();
});
it('FE-ADMIN-DUS-001: shows the loading placeholder until the defaults arrive', async () => {
render(<DefaultUserSettingsTab />);
expect(screen.getByText('Loading…')).toBeInTheDocument();
expect(await screen.findByText('Default User Settings')).toBeInTheDocument();
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
});
it('FE-ADMIN-DUS-002: renders every field with no reset links while nothing is set', async () => {
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
for (const name of ['Light', 'Dark', 'Auto', '°C Celsius', 'km Metric', '24h (14:30)', 'On', 'Off']) {
expect(isActive(screen.getByRole('button', { name }))).toBe(false);
}
for (const label of ['Color Mode', 'Temperature Unit', 'Distance Unit', 'Time Format', 'Display currency', 'Map Template']) {
expect(hasResetLink(label)).toBe(false);
}
expect(screen.getByTestId('map-preview')).toBeInTheDocument();
});
it('FE-ADMIN-DUS-003: a failing load still renders the panel with built-in defaults', async () => {
server.use(http.get('/api/admin/default-user-settings', () => HttpResponse.json({}, { status: 500 })));
render(<DefaultUserSettingsTab />);
expect(await screen.findByText('Default User Settings')).toBeInTheDocument();
expect(hasResetLink('Map engine')).toBe(false);
expect(isActive(screen.getByRole('button', { name: 'Standard (free)' }))).toBe(true);
});
it('FE-ADMIN-DUS-004: picking a colour mode saves it and confirms with a toast', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults();
withToast();
await screen.findByText('Default User Settings');
await user.click(screen.getByRole('button', { name: 'Dark' }));
expect(await screen.findByText('Default saved')).toBeInTheDocument();
expect(puts).toEqual([{ dark_mode: 'dark' }]);
await waitFor(() => expect(isActive(screen.getByRole('button', { name: 'Dark' }))).toBe(true));
expect(resetLink('Color Mode')).toBeInTheDocument();
});
it('FE-ADMIN-DUS-005: a legacy boolean dark_mode still highlights the matching option', async () => {
stubDefaults({ dark_mode: true });
const { unmount } = render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
expect(isActive(screen.getByRole('button', { name: 'Dark' }))).toBe(true);
expect(isActive(screen.getByRole('button', { name: 'Light' }))).toBe(false);
unmount();
stubDefaults({ dark_mode: false });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
expect(isActive(screen.getByRole('button', { name: 'Light' }))).toBe(true);
expect(isActive(screen.getByRole('button', { name: 'Auto' }))).toBe(false);
});
it('FE-ADMIN-DUS-006: unit and time-format options each save their own key', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults();
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await user.click(screen.getByRole('button', { name: '°F Fahrenheit' }));
await waitFor(() => expect(resetLink('Temperature Unit')).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: 'mi Imperial' }));
await waitFor(() => expect(resetLink('Distance Unit')).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: '12h (2:30 PM)' }));
await waitFor(() => expect(puts).toEqual([
{ temperature_unit: 'fahrenheit' },
{ distance_unit: 'imperial' },
{ time_format: '12h' },
]));
});
it('FE-ADMIN-DUS-007: a set default gets a reset link that clears it server-side', async () => {
const user = userEvent.setup();
const { puts, state } = stubDefaults({ temperature_unit: 'celsius' });
withToast();
await screen.findByText('Default User Settings');
expect(isActive(screen.getByRole('button', { name: '°C Celsius' }))).toBe(true);
await user.click(resetLink('Temperature Unit'));
expect(await screen.findByText('Reset to built-in default')).toBeInTheDocument();
expect(puts).toEqual([{ temperature_unit: null }]);
expect(state.temperature_unit).toBeUndefined();
await waitFor(() => expect(hasResetLink('Temperature Unit')).toBe(false));
});
it('FE-ADMIN-DUS-008: the currency picker saves the chosen code and can be reset', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ default_currency: 'USD' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await pickFromSelect(user, 'USD $', 'EUR €');
await waitFor(() => expect(puts).toEqual([{ default_currency: 'EUR' }]));
await waitFor(() => expect(screen.getByRole('button', { name: 'EUR €' })).toBeInTheDocument());
await user.click(resetLink('Display currency'));
await waitFor(() => expect(puts).toHaveLength(2));
expect(puts[1]).toEqual({ default_currency: null });
});
it('FE-ADMIN-DUS-009: the blur-booking-codes options save booleans', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults();
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await user.click(screen.getByRole('button', { name: 'On' }));
await waitFor(() => expect(isActive(screen.getByRole('button', { name: 'On' }))).toBe(true));
await user.click(screen.getByRole('button', { name: 'Off' }));
await waitFor(() => expect(puts).toEqual([
{ blur_booking_codes: true },
{ blur_booking_codes: false },
]));
});
it('FE-ADMIN-DUS-010: the tile preset dropdown fills the URL field and hands it to the preview', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults();
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await pickFromSelect(user, 'Select template...', 'CartoDB Dark');
const url = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
await waitFor(() => expect(puts).toEqual([{ map_tile_url: url }]));
expect(screen.getByPlaceholderText(TILE_PLACEHOLDER)).toHaveValue(url);
expect(screen.getByTestId('map-preview')).toHaveAttribute('data-tile', url);
});
it('FE-ADMIN-DUS-011: a hand-typed tile URL is saved on blur', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults();
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
const input = screen.getByPlaceholderText(TILE_PLACEHOLDER);
// userEvent reads {...} as key descriptors, so the placeholders are omitted here
await user.type(input, 'https://tiles.example.org/tile.png');
fireEvent.blur(input);
await waitFor(() => expect(puts).toEqual([{ map_tile_url: 'https://tiles.example.org/tile.png' }]));
expect(screen.getByTestId('map-preview')).toHaveAttribute('data-tile', 'https://tiles.example.org/tile.png');
});
it('FE-ADMIN-DUS-012: resetting the tile URL clears the input too', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_tile_url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
expect(screen.getByRole('button', { name: 'OpenStreetMap DE' })).toBeInTheDocument();
await user.click(resetLink('Map Template'));
await waitFor(() => expect(puts).toEqual([{ map_tile_url: null }]));
await waitFor(() => expect(screen.getByPlaceholderText(TILE_PLACEHOLDER)).toHaveValue(''));
});
it('FE-ADMIN-DUS-013: leaflet hides the GL-only token and style fields', async () => {
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
expect(isActive(screen.getByRole('button', { name: 'Standard (free)' }))).toBe(true);
expect(screen.queryByText('Map style')).not.toBeInTheDocument();
expect(screen.queryByText('Shared Mapbox token')).not.toBeInTheDocument();
});
it('FE-ADMIN-DUS-014: switching to Mapbox stores the provider with its own style slot', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults();
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await user.click(screen.getByRole('button', { name: 'Mapbox (3D)' }));
await waitFor(() => expect(puts).toEqual([{ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD }]));
expect(await screen.findByText('Shared Mapbox token')).toBeInTheDocument();
expect(screen.getByDisplayValue(MAPBOX_STANDARD)).toBeInTheDocument();
});
it('FE-ADMIN-DUS-015: switching to MapLibre stores the OpenFreeMap default and hides the token field', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults();
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await user.click(screen.getByRole('button', { name: 'MapLibre (OpenFreeMap)' }));
await waitFor(() => expect(puts).toEqual([{ map_provider: 'maplibre-gl', maplibre_style: OFM_LIBERTY }]));
expect(await screen.findByText('Map style')).toBeInTheDocument();
expect(screen.queryByText('Shared Mapbox token')).not.toBeInTheDocument();
expect(screen.getByDisplayValue(OFM_LIBERTY)).toBeInTheDocument();
});
it('FE-ADMIN-DUS-016: switching back to the standard engine only stores the provider', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD });
render(<DefaultUserSettingsTab />);
await screen.findByText('Map style');
await user.click(screen.getByRole('button', { name: 'Standard (free)' }));
await waitFor(() => expect(puts).toEqual([{ map_provider: 'leaflet' }]));
await waitFor(() => expect(screen.queryByText('Map style')).not.toBeInTheDocument());
});
it('FE-ADMIN-DUS-017: a Mapbox default holding an OpenFreeMap style falls back to the Mapbox standard', async () => {
stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: OFM_LIBERTY });
render(<DefaultUserSettingsTab />);
await screen.findByText('Map style');
expect(screen.getByDisplayValue(MAPBOX_STANDARD)).toBeInTheDocument();
});
it('FE-ADMIN-DUS-018: a stored Mapbox style survives while the standard engine is active', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_provider: 'leaflet', mapbox_style: MAPBOX_DARK });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
expect(screen.queryByText('Map style')).not.toBeInTheDocument();
// Switching to Mapbox re-uses the stored slot instead of resetting it
await user.click(screen.getByRole('button', { name: 'Mapbox (3D)' }));
await waitFor(() => expect(puts).toEqual([{ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_DARK }]));
expect(screen.getByDisplayValue(MAPBOX_DARK)).toBeInTheDocument();
});
it('FE-ADMIN-DUS-019: the shared Mapbox token is stored on blur and cleared by its reset link', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_access_token: 'pk.old' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Shared Mapbox token');
const input = screen.getByPlaceholderText('pk.eyJ…');
expect(input).toHaveValue('pk.old');
await user.clear(input);
await user.type(input, 'pk.new');
fireEvent.blur(input);
await waitFor(() => expect(puts).toEqual([{ mapbox_access_token: 'pk.new' }]));
// Clicking the reset link also blurs the field again, so only the last PUT is checked
await user.click(resetLink('Shared Mapbox token'));
await waitFor(() => expect(puts[puts.length - 1]).toEqual({ mapbox_access_token: null }));
await waitFor(() => expect(screen.getByPlaceholderText('pk.eyJ…')).toHaveValue(''));
});
it('FE-ADMIN-DUS-020: a hand-typed MapLibre style is normalised to OpenFreeMap on blur', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_provider: 'maplibre-gl', maplibre_style: OFM_LIBERTY });
render(<DefaultUserSettingsTab />);
await screen.findByText('Map style');
const input = screen.getByDisplayValue(OFM_LIBERTY);
await user.clear(input);
await user.type(input, 'https://example.com/custom.json');
fireEvent.blur(input);
await waitFor(() => expect(puts).toEqual([{ maplibre_style: OFM_LIBERTY }]));
expect(input).toHaveValue(OFM_LIBERTY);
});
it('FE-ADMIN-DUS-021: the style dropdown writes the picked preset into the active provider slot', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD });
render(<DefaultUserSettingsTab />);
await screen.findByText('Map style');
await pickFromSelect(user, 'Mapbox Standard', 'Navigation Night');
await waitFor(() => expect(puts).toEqual([{ mapbox_style: MAPBOX_NAV_NIGHT }]));
expect(screen.getByDisplayValue(MAPBOX_NAV_NIGHT)).toBeInTheDocument();
});
it('FE-ADMIN-DUS-022: resetting the style restores the provider default in the field', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_DARK });
render(<DefaultUserSettingsTab />);
await screen.findByText('Map style');
await user.click(resetLink('Map style'));
await waitFor(() => expect(puts).toEqual([{ mapbox_style: null }]));
await waitFor(() => expect(screen.getByDisplayValue(MAPBOX_STANDARD)).toBeInTheDocument());
});
it('FE-ADMIN-DUS-023: the Mapbox 3D and quality options start on their built-in defaults and save their own keys', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD });
render(<DefaultUserSettingsTab />);
await screen.findByText('3D buildings & terrain');
// 3D defaults to on, quality mode to off when neither is stored
const threeD = within(screen.getByText('3D buildings & terrain').closest('div') as HTMLElement);
expect(isActive(threeD.getByRole('button', { name: 'On' }))).toBe(true);
const quality = within(screen.getByText('High-quality mode').closest('div') as HTMLElement);
expect(isActive(quality.getByRole('button', { name: 'Off' }))).toBe(true);
await user.click(threeD.getByRole('button', { name: 'Off' }));
await waitFor(() => expect(puts).toEqual([{ mapbox_3d_enabled: false }]));
await user.click(quality.getByRole('button', { name: 'On' }));
await waitFor(() => expect(puts).toHaveLength(2));
expect(puts[1]).toEqual({ mapbox_quality_mode: true });
});
it('FE-ADMIN-DUS-024: a rejected save surfaces the request error instead of a success toast', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/default-user-settings', () => HttpResponse.json({})),
http.put('/api/admin/default-user-settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
);
withToast();
await screen.findByText('Default User Settings');
await user.click(screen.getByRole('button', { name: 'Dark' }));
expect(await screen.findByText(/Request failed with status code 500/)).toBeInTheDocument();
expect(screen.queryByText('Default saved')).not.toBeInTheDocument();
});
it('FE-ADMIN-DUS-025: a rejected reset surfaces the request error', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/default-user-settings', () => HttpResponse.json({ time_format: '12h' })),
http.put('/api/admin/default-user-settings', () => HttpResponse.json({ error: 'nope' }, { status: 503 })),
);
withToast();
await screen.findByText('Default User Settings');
await user.click(resetLink('Time Format'));
expect(await screen.findByText(/Request failed with status code 503/)).toBeInTheDocument();
expect(screen.queryByText('Reset to built-in default')).not.toBeInTheDocument();
});
});
@@ -6,21 +6,10 @@ import { useToast } from '../shared/Toast'
import Section from '../Settings/Section'
import CustomSelect from '../shared/CustomSelect'
import { MapView } from '../Map/MapView'
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
import type { DistanceUnit, Place } from '../../types'
import { normalizeTileUrl } from '../../utils/tileUrl'
import {
MAPBOX_DEFAULT_STYLE,
defaultStyleForProvider,
getStylePresets,
isOpenFreeMapStyle,
normalizeStyleForProvider,
styleSettingKey,
type GlMapProvider,
} from '../Map/glProviders'
import type { Place } from '../../types'
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
@@ -29,30 +18,11 @@ const MAP_PRESETS = [
type Defaults = {
temperature_unit?: string
distance_unit?: DistanceUnit
dark_mode?: string | boolean
time_format?: string
default_currency?: string
route_calculation?: boolean
blur_booking_codes?: boolean
map_tile_url?: string
map_provider?: string
mapbox_access_token?: string
mapbox_style?: string
maplibre_style?: string
mapbox_3d_enabled?: boolean
mapbox_quality_mode?: boolean
}
type MapProvider = 'leaflet' | GlMapProvider
function normalizeProvider(value: unknown): MapProvider {
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
}
/** Only the GL providers keep a style — Leaflet is handled by its callers. */
function styleForProvider(provider: GlMapProvider, style?: string | null): string {
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
return normalizeStyleForProvider(provider, style)
}
function OptionRow({
@@ -66,10 +36,10 @@ function OptionRow({
}) {
return (
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
{label}
</label>
{hint && <p className="text-xs mb-2 text-content-faint">{hint}</p>}
{hint && <p className="text-xs mb-2" style={{ color: 'var(--text-faint)' }}>{hint}</p>}
<div className="flex gap-3 flex-wrap">{children}</div>
</div>
)
@@ -90,7 +60,7 @@ function OptionButton({
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
@@ -108,16 +78,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const [defaults, setDefaults] = useState<Defaults>({})
const [loaded, setLoaded] = useState(false)
const [mapTileUrl, setMapTileUrl] = useState('')
const [mapboxToken, setMapboxToken] = useState('')
const [mapboxStyle, setMapboxStyle] = useState('')
useEffect(() => {
adminApi.getDefaultUserSettings().then((data: Defaults) => {
const provider = normalizeProvider(data.map_provider)
setDefaults(data)
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
setMapboxToken(data.mapbox_access_token || '')
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
setMapTileUrl(data.map_tile_url || '')
setLoaded(true)
}).catch(() => setLoaded(true))
}, [])
@@ -137,11 +102,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
setDefaults(updated)
if (key === 'map_tile_url') setMapTileUrl('')
if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'mapbox_style' || key === 'maplibre_style') {
const provider = normalizeProvider(defaults.map_provider)
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
}
toast.success(t('admin.defaultSettings.reset'))
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.error'))
@@ -154,8 +114,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
isSet(field) ? (
<button
onClick={() => reset(field)}
className="text-xs ml-2 text-content-faint underline"
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
className="text-xs ml-2"
style={{ color: 'var(--text-faint)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer' }}
>
{t('admin.defaultSettings.resetToBuiltIn')}
</button>
@@ -171,6 +131,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
lng: 2.3522,
address: null,
category_id: null,
icon: null,
price: null,
currency: null,
image_url: null,
@@ -187,28 +148,14 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
}], [])
if (!loaded) {
return <p className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontStyle: 'italic', padding: 16 }}>Loading</p>
return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic', padding: 16 }}>Loading</p>
}
const darkMode = defaults.dark_mode
const mapProvider = normalizeProvider(defaults.map_provider)
const glStylePresets = mapProvider === 'leaflet' ? [] : getStylePresets(mapProvider)
const styleKey: keyof Defaults = mapProvider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
const saveMapProvider = (nextProvider: MapProvider) => {
const patch: Partial<Defaults> = { map_provider: nextProvider }
if (nextProvider !== 'leaflet') {
// Load + save the new provider's own style slot so the other provider's style is kept.
const slot = nextProvider === 'maplibre-gl' ? defaults.maplibre_style : defaults.mapbox_style
const nextStyle = styleForProvider(nextProvider, slot)
setMapboxStyle(nextStyle)
patch[styleSettingKey(nextProvider)] = nextStyle
}
save(patch)
}
return (
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>
<p className="text-sm" style={{ color: 'var(--text-faint)', marginTop: -8 }}>
{t('admin.defaultSettings.description')}
</p>
@@ -245,22 +192,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
))}
</OptionRow>
{/* Distance */}
<OptionRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
{([
{ value: 'metric', label: 'km Metric' },
{ value: 'imperial', label: 'mi Imperial' },
] as const).map(opt => (
<OptionButton
key={opt.value}
active={defaults.distance_unit === opt.value}
onClick={() => save({ distance_unit: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Time Format */}
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
{([
@@ -277,22 +208,21 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
))}
</OptionRow>
{/* Default Currency */}
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('settings.currency')} <ResetButton field="default_currency" />
</label>
<CustomSelect
value={defaults.default_currency || ''}
onChange={(value: string) => { if (value) save({ default_currency: value }) }}
placeholder={t('settings.currency')}
searchable
options={currenciesWith(defaults.default_currency).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
size="sm"
style={{ maxWidth: 240 }}
/>
<p className="text-xs mt-1 text-content-faint">{t('settings.currencyHint')}</p>
</div>
{/* Route Calculation */}
<OptionRow label={<>{t('settings.routeCalculation')} <ResetButton field="route_calculation" /></>}>
{([
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
] as const).map(opt => (
<OptionButton
key={String(opt.value)}
active={defaults.route_calculation === opt.value}
onClick={() => save({ route_calculation: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Blur Booking Codes */}
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
@@ -312,7 +242,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{/* Map Tile URL */}
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
{t('settings.mapTemplate')}
<ResetButton field="map_tile_url" />
</label>
@@ -329,10 +259,10 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
value={mapTileUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
onBlur={() => save({ map_tile_url: mapTileUrl })}
placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs mt-1 text-content-faint">{t('settings.mapDefaultHint')}</p>
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{t('settings.mapDefaultHint')}</p>
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{React.createElement(MapView as any, {
@@ -355,105 +285,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
})}
</div>
</div>
{/* ── Map provider / instance-wide Mapbox ───────────────────────── */}
<div style={{ borderTop: '1px solid var(--border-primary)', paddingTop: 20, marginTop: 4 }}>
<OptionRow
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
hint={t('admin.defaultSettings.mapProviderHint')}
>
{([
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
] as const).map(opt => (
<OptionButton
key={opt.value}
active={mapProvider === opt.value}
onClick={() => saveMapProvider(opt.value)}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{mapProvider !== 'leaflet' && (
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
{mapProvider === 'mapbox-gl' && (
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.mapboxToken')}
<ResetButton field="mapbox_access_token" />
</label>
<input
type="text"
value={mapboxToken}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxToken(e.target.value)}
onBlur={() => save({ mapbox_access_token: mapboxToken })}
placeholder="pk.eyJ…"
spellCheck={false}
autoComplete="off"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p>
</div>
)}
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.mapboxStyle')}
<ResetButton field={styleKey} />
</label>
<CustomSelect
value={mapboxStyle}
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ [styleKey]: value }) } }}
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
options={glStylePresets.map(p => ({ value: p.url, label: p.name }))}
size="sm"
style={{ marginBottom: 8 }}
/>
<input
type="text"
value={mapboxStyle}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
onBlur={() => {
const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle)
setMapboxStyle(nextStyle)
save({ [styleKey]: nextStyle })
}}
placeholder={defaultStyleForProvider(mapProvider)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
{mapProvider === 'mapbox-gl' && (
<>
<OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
{([
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
] as const).map(opt => (
<OptionButton key={String(opt.value)} active={(defaults.mapbox_3d_enabled ?? true) === opt.value} onClick={() => save({ mapbox_3d_enabled: opt.value })}>
{opt.label}
</OptionButton>
))}
</OptionRow>
<OptionRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
{([
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
] as const).map(opt => (
<OptionButton key={String(opt.value)} active={(defaults.mapbox_quality_mode ?? false) === opt.value} onClick={() => save({ mapbox_quality_mode: opt.value })}>
{opt.label}
</OptionButton>
))}
</OptionRow>
</>
)}
</div>
)}
</div>
</Section>
)
}
@@ -1,5 +1,5 @@
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-016
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-010
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -110,20 +110,7 @@ describe('DevNotificationsPanel', () => {
});
});
it('FE-ADMIN-DEVNOTIF-008: the server error field is what the toast shows', async () => {
server.use(
http.post('/api/admin/dev/test-notification', () =>
HttpResponse.json({ error: 'No channel configured' }, { status: 500 }),
),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText('No channel configured');
});
it('FE-ADMIN-DEVNOTIF-008b: a failure without an error field falls back to the generic text', async () => {
it('FE-ADMIN-DEVNOTIF-008: error toast shown on API failure', async () => {
server.use(
http.post('/api/admin/dev/test-notification', () =>
HttpResponse.json({ message: 'Server error' }, { status: 500 }),
@@ -133,7 +120,7 @@ describe('DevNotificationsPanel', () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText('Failed');
await screen.findByText(/failed|error/i);
});
it('FE-ADMIN-DEVNOTIF-009: changing trip selector updates payload targetId', async () => {
@@ -170,141 +157,4 @@ describe('DevNotificationsPanel', () => {
await screen.findByText('User-Scoped Events');
expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-011: the remaining self/admin type buttons each fire their own event', async () => {
const bodies: Record<string, unknown>[] = [];
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>);
return HttpResponse.json({ ok: true });
}),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Boolean → Me').closest('button')!);
await screen.findByText('Sent: boolean-me');
await user.click(screen.getByText('Navigate → Me').closest('button')!);
await screen.findByText('Sent: navigate-me');
await user.click(screen.getByText('Simple → All Admins').closest('button')!);
await screen.findByText('Sent: simple-admins');
await user.click(screen.getByText('version_available').closest('button')!);
await screen.findByText('Sent: version_available');
expect(bodies[0]).toMatchObject({
event: 'test_boolean',
scope: 'user',
targetId: ADMIN_USER.id,
inApp: {
type: 'boolean',
positiveCallback: { action: 'test_approve', payload: {} },
negativeCallback: { action: 'test_deny', payload: {} },
},
});
expect(bodies[1]).toMatchObject({ event: 'test_navigate', scope: 'user', targetId: ADMIN_USER.id });
expect(bodies[2]).toMatchObject({ event: 'test_simple', scope: 'admin', targetId: 0 });
expect(bodies[3]).toMatchObject({ event: 'version_available', scope: 'admin', targetId: 0, params: { version: '9.9.9-test' } });
});
it('FE-ADMIN-DEVNOTIF-012: every trip-scoped button carries the selected trip and the actor', async () => {
const bodies: Record<string, unknown>[] = [];
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>);
return HttpResponse.json({ ok: true });
}),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Trip-Scoped Events');
const [tripSelect] = screen.getAllByRole('combobox');
const tripId = Number((tripSelect as HTMLSelectElement).value);
for (const label of ['trip_reminder', 'photos_shared', 'collab_message', 'packing_tagged']) {
await user.click(screen.getByText(label).closest('button')!);
await screen.findByText(`Sent: ${label}`);
}
expect(bodies.map(b => b.event)).toEqual(['trip_reminder', 'photos_shared', 'collab_message', 'packing_tagged']);
for (const body of bodies) {
expect(body.scope).toBe('trip');
expect(body.targetId).toBe(tripId);
expect(body.params).toMatchObject({ trip: 'Paris Adventure', tripId: String(tripId) });
}
expect(bodies[1].params).toMatchObject({ actor: 'testadmin', count: '5' });
expect(bodies[2].params).toMatchObject({ preview: 'This is a test message preview.' });
expect(bodies[3].params).toMatchObject({ category: 'Clothing' });
});
it('FE-ADMIN-DEVNOTIF-013: user-scoped events target the picked recipient', async () => {
const bodies: Record<string, unknown>[] = [];
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>);
return HttpResponse.json({ ok: true });
}),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('User-Scoped Events');
const userSelect = screen.getAllByRole('combobox')[1] as HTMLSelectElement;
const aliceOption = Array.from(userSelect.querySelectorAll('option')).find(
o => (o.textContent ?? '').includes('alice'),
)!;
await user.selectOptions(userSelect, aliceOption.value);
const aliceId = Number(aliceOption.value);
await user.click(screen.getByText('trip_invite').closest('button')!);
await screen.findByText(`Sent: trip_invite-${aliceId}`);
await user.click(screen.getByText('vacay_invite').closest('button')!);
await screen.findByText(`Sent: vacay_invite-${aliceId}`);
expect(bodies[0]).toMatchObject({
event: 'trip_invite',
scope: 'user',
targetId: aliceId,
params: { actor: 'testadmin', invitee: 'alice@example.com' },
});
expect(bodies[1]).toMatchObject({
event: 'vacay_invite',
scope: 'user',
targetId: aliceId,
params: { actor: 'testadmin', planId: '1' },
});
});
it('FE-ADMIN-DEVNOTIF-014: the User-Scoped section is hidden when no users come back', async () => {
server.use(http.get('/api/admin/users', () => HttpResponse.json({ users: [] })));
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Trip-Scoped Events');
expect(screen.queryByText('User-Scoped Events')).not.toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-015: failing lookups leave both scoped sections out without crashing', async () => {
server.use(
http.get('/api/trips', () => HttpResponse.error()),
http.get('/api/admin/users', () => HttpResponse.error()),
);
render(<><ToastContainer /><DevNotificationsPanel /></>);
expect(await screen.findByText('Type Testing')).toBeInTheDocument();
await waitFor(() => expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument());
expect(screen.queryByText('User-Scoped Events')).not.toBeInTheDocument();
expect(screen.getByText('Admin-Scoped Events')).toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-016: hovering a trigger paints and restores its background', async () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
const btn = screen.getByText('Simple → Me').closest('button')!;
fireEvent.mouseEnter(btn);
expect(btn.style.background).toBe('var(--bg-hover)');
fireEvent.mouseLeave(btn);
expect(btn.style.background).toBe('var(--bg-card)');
});
});
@@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react'
import { adminApi, tripsApi } from '../../api/client'
import { getApiErrorMessage } from '../../utils/apiError'
import { useAuthStore } from '../../store/authStore'
import { useToast } from '../shared/Toast'
import {
@@ -47,8 +46,8 @@ export default function DevNotificationsPanel(): React.ReactElement {
try {
await adminApi.sendTestNotification(payload)
toast.success(`Sent: ${label}`)
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, 'Failed'))
} catch (err: any) {
toast.error(err.message || 'Failed')
} finally {
setSending(null)
}
@@ -69,7 +68,8 @@ export default function DevNotificationsPanel(): React.ReactElement {
<button
onClick={onClick}
disabled={sending !== null}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full border-edge bg-surface-card"
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
>
@@ -78,8 +78,8 @@ export default function DevNotificationsPanel(): React.ReactElement {
<Icon className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-content">{label}</p>
<p className="text-xs truncate text-content-faint">{sub}</p>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>{sub}</p>
</div>
{sending === id && (
<div className="w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin flex-shrink-0" />
@@ -88,14 +88,15 @@ export default function DevNotificationsPanel(): React.ReactElement {
)
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
<h3 className="text-sm font-semibold mb-3 text-content-secondary">{children}</h3>
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>{children}</h3>
)
const TripSelector = () => (
<select
value={selectedTripId ?? ''}
onChange={e => setSelectedTripId(Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border text-sm mb-3 border-edge bg-surface-card text-content"
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
>
{trips.map(trip => <option key={trip.id} value={trip.id}>{trip.title}</option>)}
</select>
@@ -105,7 +106,8 @@ export default function DevNotificationsPanel(): React.ReactElement {
<select
value={selectedUserId ?? ''}
onChange={e => setSelectedUserId(Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border text-sm mb-3 border-edge bg-surface-card text-content"
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
>
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.email})</option>)}
</select>
@@ -114,10 +116,10 @@ export default function DevNotificationsPanel(): React.ReactElement {
return (
<div className="space-y-8">
<div className="flex items-center gap-2">
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold bg-[#fbbf24] text-[#000]">
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold" style={{ background: '#fbbf24', color: '#000' }}>
DEV ONLY
</div>
<span className="text-sm font-medium text-content">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
Notification Testing
</span>
</div>
@@ -125,7 +127,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
{/* ── Type Testing ─────────────────────────────────────────────────── */}
<div>
<SectionTitle>Type Testing</SectionTitle>
<p className="text-xs mb-3 text-content-muted">
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
Test how each in-app notification type renders, sent to yourself.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
@@ -173,7 +175,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
{trips.length > 0 && (
<div>
<SectionTitle>Trip-Scoped Events</SectionTitle>
<p className="text-xs mb-3 text-content-muted">
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
Fires each trip event to all members of the selected trip (excluding yourself).
</p>
<TripSelector />
@@ -226,7 +228,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
{users.length > 0 && (
<div>
<SectionTitle>User-Scoped Events</SectionTitle>
<p className="text-xs mb-3 text-content-muted">
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
Fires each user event to the selected recipient.
</p>
<UserSelector />
@@ -264,7 +266,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
{/* ── Admin-Scoped Events ──────────────────────────────────────────── */}
<div>
<SectionTitle>Admin-Scoped Events</SectionTitle>
<p className="text-xs mb-3 text-content-muted">
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
Fires to all admin users.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
+254 -374
View File
@@ -1,502 +1,382 @@
import {
BookOpen,
Bug,
Calendar,
ChevronDown,
ChevronUp,
Coffee,
ExternalLink,
Heart,
Lightbulb,
Loader2,
Tag,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import apiClient from '../../api/client';
import { getLocaleForLanguage, useTranslation } from '../../i18n';
import { useState, useEffect } from 'react'
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react'
import { getLocaleForLanguage, useTranslation } from '../../i18n'
import apiClient from '../../api/client'
const REPO = 'liketrek/TREK';
const PER_PAGE = 10;
const REPO = 'mauriceboe/TREK'
const PER_PAGE = 10
interface GithubRelease {
id: number;
prerelease: boolean;
tag_name: string;
name: string | null;
body: string | null;
published_at: string | null;
created_at: string;
author: { login: string } | null;
[key: string]: unknown;
id: number
prerelease: boolean
[key: string]: unknown
}
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
const { t, language } = useTranslation();
const [releases, setReleases] = useState<GithubRelease[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const { t, language } = useTranslation()
const [releases, setReleases] = useState<GithubRelease[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const fetchReleases = async (pageNum = 1, append = false) => {
try {
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } });
const data = Array.isArray(res.data) ? res.data : [];
setReleases((prev) => (append ? [...prev, ...data] : data));
setHasMore(data.length === PER_PAGE);
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
const data = Array.isArray(res.data) ? res.data : []
setReleases(prev => append ? [...prev, ...data] : data)
setHasMore(data.length === PER_PAGE)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Unknown error');
setError(err instanceof Error ? err.message : 'Unknown error')
}
};
}
useEffect(() => {
setLoading(true);
fetchReleases(1).finally(() => setLoading(false));
}, []);
setLoading(true)
fetchReleases(1).finally(() => setLoading(false))
}, [])
const handleLoadMore = async () => {
const next = page + 1;
setLoadingMore(true);
await fetchReleases(next, true);
setPage(next);
setLoadingMore(false);
};
const next = page + 1
setLoadingMore(true)
await fetchReleases(next, true)
setPage(next)
setLoadingMore(false)
}
const toggleExpand = (id) => {
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
};
setExpanded(prev => ({ ...prev, [id]: !prev[id] }))
}
const formatDate = (dateStr) => {
const d = new Date(dateStr);
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' });
};
const d = new Date(dateStr)
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' })
}
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
const renderBody = (body) => {
if (!body) return null;
const lines = body.split('\n');
const elements = [];
let listItems = [];
if (!body) return null
const lines = body.split('\n')
const elements = []
let listItems = []
const flushList = () => {
if (listItems.length > 0) {
elements.push(
<ul key={`ul-${elements.length}`} className="my-2 space-y-1">
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
{listItems.map((item, i) => (
<li key={i} className="flex gap-2 text-xs text-content-muted">
<span
className="mt-1.5 h-1 w-1 flex-shrink-0 rounded-full"
style={{ background: 'var(--text-faint)' }}
/>
<li key={i} className="flex gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--text-faint)' }} />
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
</li>
))}
</ul>
);
listItems = [];
)
listItems = []
}
};
}
const escapeHtml = (str) =>
str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const escapeHtml = (str) => str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
const inlineFormat = (text) => {
return escapeHtml(text)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(
/`(.+?)`/g,
'<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>'
)
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#';
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`;
});
};
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#'
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`
})
}
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
flushList();
continue;
}
const trimmed = line.trim()
if (!trimmed) { flushList(); continue }
if (trimmed.startsWith('### ')) {
flushList();
flushList()
elements.push(
<h4 key={elements.length} className="mb-1 mt-3 text-xs font-semibold text-content">
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
{trimmed.slice(4)}
</h4>
);
)
} else if (trimmed.startsWith('## ')) {
flushList();
flushList()
elements.push(
<h3 key={elements.length} className="mb-1 mt-3 text-sm font-semibold text-content">
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
{trimmed.slice(3)}
</h3>
);
)
} else if (/^[-*] /.test(trimmed)) {
listItems.push(trimmed.slice(2));
listItems.push(trimmed.slice(2))
} else {
flushList();
flushList()
elements.push(
<p
key={elements.length}
className="my-1 text-xs text-content-muted"
<p key={elements.length} className="text-xs my-1" style={{ color: 'var(--text-muted)' }}
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
/>
);
)
}
}
flushList();
return elements;
};
flushList()
return elements
}
return (
<div className="space-y-3">
{/* Support cards */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<a
href="https://ko-fi.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ff5e5b';
e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div
className="bg-[#ff5e5b15]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Coffee size={20} className="text-[#ff5e5b]" />
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ff5e5b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Coffee size={20} style={{ color: '#ff5e5b' }} />
</div>
<div>
<div className="text-sm font-semibold text-content">Ko-fi</div>
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Ko-fi</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://buymeacoffee.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ffdd00';
e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div
className="bg-[#ffdd0015]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Heart size={20} className="text-[#ffdd00]" />
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ffdd0015', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Heart size={20} style={{ color: '#ffdd00' }} />
</div>
<div>
<div className="text-sm font-semibold text-content">Buy Me a Coffee</div>
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Buy Me a Coffee</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://discord.gg/NhZBDSd4qW"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#5865F2';
e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div
className="bg-[#5865F215]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#5865F215', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
</div>
<div>
<div className="text-sm font-semibold text-content">Discord</div>
<div className="text-xs text-content-faint">Join the community</div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Discord</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>Join the community</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<a
href="https://github.com/liketrek/TREK/issues/new?template=bug_report.yml"
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ef4444';
e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div
className="bg-[#ef444415]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Bug size={20} className="text-[#ef4444]" />
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ef444415', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Bug size={20} style={{ color: '#ef4444' }} />
</div>
<div>
<div className="text-sm font-semibold text-content">{t('settings.about.reportBug')}</div>
<div className="text-xs text-content-faint">{t('settings.about.reportBugHint')}</div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.reportBug')}</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.reportBugHint')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://github.com/liketrek/TREK/discussions/new?category=feature-requests"
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#f59e0b';
e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div
className="bg-[#f59e0b15]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Lightbulb size={20} className="text-[#f59e0b]" />
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#f59e0b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Lightbulb size={20} style={{ color: '#f59e0b' }} />
</div>
<div>
<div className="text-sm font-semibold text-content">{t('settings.about.featureRequest')}</div>
<div className="text-xs text-content-faint">{t('settings.about.featureRequestHint')}</div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.featureRequest')}</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.featureRequestHint')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://github.com/liketrek/TREK/wiki"
href="https://github.com/mauriceboe/TREK/wiki"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#6366f1';
e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div
className="bg-[#6366f115]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<BookOpen size={20} className="text-[#6366f1]" />
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#6366f115', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<BookOpen size={20} style={{ color: '#6366f1' }} />
</div>
<div>
<div className="text-sm font-semibold text-content">Wiki</div>
<div className="text-xs text-content-faint">{t('settings.about.wikiHint')}</div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Wiki</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.wikiHint')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
</div>
{/* Loading / Error / Releases */}
{loading ? (
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin text-content-muted" />
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="p-8 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin" style={{ color: 'var(--text-muted)' }} />
</div>
</div>
) : error ? (
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="p-6 text-center">
<p className="text-sm text-content-muted">{t('admin.github.error')}</p>
<p className="mt-1 text-xs text-content-faint">{error}</p>
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.github.error')}</p>
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{error}</p>
</div>
</div>
) : (
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
<div className="flex items-center justify-between border-b border-edge-secondary px-5 py-4">
<div>
<h2 className="font-semibold text-content">{t('admin.github.title')}</h2>
<p className="mt-0.5 text-xs text-content-faint">{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
</div>
<a
href={`https://github.com/${REPO}/releases`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-lg bg-surface-secondary px-3 py-1.5 text-xs font-medium text-content-muted transition-colors"
>
<ExternalLink size={12} />
GitHub
</a>
</div>
{/* Timeline */}
<div className="px-5 py-4">
<div className="relative">
{/* Timeline line */}
<div
className="absolute bottom-3 left-[11px] top-3 w-px"
style={{ background: 'var(--border-primary)' }}
/>
<div className="space-y-0">
{(isPrerelease ? releases : releases.filter((r) => !r.prerelease)).map((release, idx) => {
const isLatest = idx === 0;
const isExpanded = expanded[release.id];
return (
<div key={release.id} className="relative pb-5 pl-8">
{/* Timeline dot */}
<div
className="absolute left-0 top-1 flex h-[23px] w-[23px] items-center justify-center rounded-full border-2"
style={{
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
}}
>
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
</div>
{/* Release content */}
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-content">{release.tag_name}</span>
{isLatest && (
<span className="rounded-full bg-[rgba(34,197,94,0.12)] px-2 py-0.5 text-[10px] font-semibold text-[#16a34a]">
{t('admin.github.latest')}
</span>
)}
{release.prerelease && (
<span className="rounded-full bg-[rgba(245,158,11,0.12)] px-2 py-0.5 text-[10px] font-semibold text-[#d97706]">
{t('admin.github.prerelease')}
</span>
)}
</div>
{release.name && release.name !== release.tag_name && (
<p className="mt-0.5 text-xs font-medium text-content-muted">{release.name}</p>
)}
<div className="mt-1 flex items-center gap-3">
<span className="flex items-center gap-1 text-[11px] text-content-faint">
<Calendar size={10} />
{formatDate(release.published_at || release.created_at)}
</span>
{release.author && (
<span className="text-[11px] text-content-faint">
{t('admin.github.by')} {release.author.login}
</span>
)}
</div>
{/* Expandable body */}
{release.body && (
<div className="mt-2">
<button
onClick={() => toggleExpand(release.id)}
className="flex items-center gap-1 text-[11px] font-medium text-content-muted transition-colors"
>
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
</button>
{isExpanded && (
<div className="mt-2 rounded-lg bg-surface-secondary p-3">{renderBody(release.body)}</div>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Load more */}
{hasMore && (
<div className="pt-2 text-center">
<button
onClick={handleLoadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 rounded-lg bg-surface-secondary px-4 py-2 text-xs font-medium text-content-muted transition-colors"
>
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
</button>
</div>
)}
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-secondary)' }}>
<div>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.github.title')}</h2>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
</div>
<a
href={`https://github.com/${REPO}/releases`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
>
<ExternalLink size={12} />
GitHub
</a>
</div>
{/* Timeline */}
<div className="px-5 py-4">
<div className="relative">
{/* Timeline line */}
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
<div className="space-y-0">
{(isPrerelease ? releases : releases.filter(r => !r.prerelease)).map((release, idx) => {
const isLatest = idx === 0
const isExpanded = expanded[release.id]
return (
<div key={release.id} className="relative pl-8 pb-5">
{/* Timeline dot */}
<div
className="absolute left-0 top-1 w-[23px] h-[23px] rounded-full flex items-center justify-center border-2"
style={{
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
}}
>
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
</div>
{/* Release content */}
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{release.tag_name}
</span>
{isLatest && (
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
style={{ background: 'rgba(34,197,94,0.12)', color: '#16a34a' }}>
{t('admin.github.latest')}
</span>
)}
{release.prerelease && (
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
style={{ background: 'rgba(245,158,11,0.12)', color: '#d97706' }}>
{t('admin.github.prerelease')}
</span>
)}
</div>
{release.name && release.name !== release.tag_name && (
<p className="text-xs font-medium mt-0.5" style={{ color: 'var(--text-muted)' }}>
{release.name}
</p>
)}
<div className="flex items-center gap-3 mt-1">
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--text-faint)' }}>
<Calendar size={10} />
{formatDate(release.published_at || release.created_at)}
</span>
{release.author && (
<span className="text-[11px]" style={{ color: 'var(--text-faint)' }}>
{t('admin.github.by')} {release.author.login}
</span>
)}
</div>
{/* Expandable body */}
{release.body && (
<div className="mt-2">
<button
onClick={() => toggleExpand(release.id)}
className="flex items-center gap-1 text-[11px] font-medium transition-colors"
style={{ color: 'var(--text-muted)' }}
>
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
</button>
{isExpanded && (
<div className="mt-2 p-3 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
{renderBody(release.body)}
</div>
)}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
{/* Load more */}
{hasMore && (
<div className="text-center pt-2">
<button
onClick={handleLoadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
>
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
</button>
</div>
)}
</div>
</div>
)}
</div>
);
)
}
@@ -1,5 +1,5 @@
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-032
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-020
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -18,23 +18,6 @@ beforeEach(() => {
resetAllStores();
});
/** Template rows carry [chevron, edit, delete]; category headers [add item, edit, delete]. */
function rowButtons(name: string, selector: string): HTMLElement[] {
const row = screen.getByText(name).closest(selector) as HTMLElement;
return within(row).getAllByRole('button');
}
const templateButtons = (name: string) => rowButtons(name, '.px-5.py-3');
const categoryButtons = (name: string) => rowButtons(name, '.bg-slate-50');
const itemButtons = (name: string) => rowButtons(name, '.group');
/** Expands the single fixture template and waits for its content. */
async function expandBeachTrip(user: ReturnType<typeof userEvent.setup>, firstChild: string) {
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText(firstChild);
}
describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-001: shows loading spinner on mount', async () => {
server.use(
@@ -517,304 +500,11 @@ describe('PackingTemplateManager', () => {
// Find the X (cancel) button in the create row — it's the last button in the create row
const createRow = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)').closest('div')!;
const createRowButtons = Array.from(createRow.querySelectorAll('button'));
const cancelBtn = createRowButtons[createRowButtons.length - 1] as HTMLElement;
const cancelBtn = Array.from(createRow.querySelectorAll('button')).at(-1) as HTMLElement;
await user.click(cancelBtn);
await waitFor(() =>
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument()
);
});
it('FE-ADMIN-PKG-021: a failing template list toasts and shows the empty state', async () => {
server.use(http.get('/api/admin/packing-templates', () => HttpResponse.error()));
render(<><ToastContainer /><PackingTemplateManager /></>);
expect(await screen.findByText('Failed to load templates')).toBeInTheDocument();
expect(screen.getByText('No templates created yet')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-022: a failing expand toasts and leaves the template without content', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.error()),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
expect(await screen.findByText('Failed to load templates')).toBeInTheDocument();
expect(screen.getByText('Add category')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-023: an empty name is not submitted and a failing create toasts', async () => {
const user = userEvent.setup();
let posts = 0;
server.use(
http.post('/api/admin/packing-templates', () => {
posts += 1;
return HttpResponse.json({ error: 'nope' }, { status: 500 });
}),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await screen.findByText('No templates created yet');
await user.click(screen.getByRole('button', { name: /new template/i }));
const input = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)');
await user.type(input, ' {Enter}');
expect(posts).toBe(0);
await user.clear(input);
await user.type(input, 'Ski trip{Enter}');
expect(await screen.findByText('Failed to create template')).toBeInTheDocument();
expect(posts).toBe(1);
});
it('FE-ADMIN-PKG-024: deleting the expanded template collapses it, a failing delete toasts', async () => {
const user = userEvent.setup();
let calls = 0;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
http.delete('/api/admin/packing-templates/1', () => {
calls += 1;
return calls === 1
? HttpResponse.json({ error: 'in use' }, { status: 500 })
: HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'Clothing');
await user.click(templateButtons('Beach Trip')[2]);
expect(await screen.findByText('Failed to delete template')).toBeInTheDocument();
expect(screen.getByText('Clothing')).toBeInTheDocument();
await user.click(templateButtons('Beach Trip')[2]);
await screen.findByText('Template deleted');
await waitFor(() => expect(screen.queryByText('Clothing')).not.toBeInTheDocument());
expect(screen.getByText('No templates created yet')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-025: a blank rename closes the editor, a failing rename toasts, blur commits', async () => {
const user = userEvent.setup();
let puts = 0;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.put('/api/admin/packing-templates/1', () => {
puts += 1;
return HttpResponse.json({ error: 'nope' }, { status: 500 });
}),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await screen.findByText('Beach Trip');
await user.click(templateButtons('Beach Trip')[1]);
await user.clear(screen.getByDisplayValue('Beach Trip'));
await user.type(screen.getByRole('textbox'), '{Enter}');
await waitFor(() => expect(screen.getByText('Beach Trip')).toBeInTheDocument());
expect(puts).toBe(0);
// Blurring the field commits the pending name — here the request fails
await user.click(templateButtons('Beach Trip')[1]);
const input = screen.getByDisplayValue('Beach Trip');
await user.clear(input);
await user.type(input, 'Winter');
await user.tab();
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
expect(puts).toBe(1);
});
it('FE-ADMIN-PKG-026: a blank category is not posted, a failing add toasts and X cancels', async () => {
const user = userEvent.setup();
let posts = 0;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [], items: [] })),
http.post('/api/admin/packing-templates/1/categories', () => {
posts += 1;
return HttpResponse.json({ error: 'nope' }, { status: 500 });
}),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'Add category');
await user.click(screen.getByText('Add category'));
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
await user.type(catInput, ' {Enter}');
expect(posts).toBe(0);
await user.clear(catInput);
await user.type(catInput, 'Electronics{Enter}');
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
const cancel = within(catInput.parentElement as HTMLElement).getAllByRole('button')[1];
await user.click(cancel);
await waitFor(() =>
expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument(),
);
});
it('FE-ADMIN-PKG-027: a blank category rename closes the editor and a failing rename toasts', async () => {
const user = userEvent.setup();
let puts = 0;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
http.put('/api/admin/packing-templates/1/categories/10', () => {
puts += 1;
return HttpResponse.json({ error: 'nope' }, { status: 500 });
}),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'Clothing');
await user.click(categoryButtons('Clothing')[1]);
await user.clear(screen.getByDisplayValue('Clothing'));
await user.tab();
await waitFor(() => expect(screen.getByText('Clothing')).toBeInTheDocument());
expect(puts).toBe(0);
await user.click(categoryButtons('Clothing')[1]);
const catInput = screen.getByDisplayValue('Clothing');
await user.clear(catInput);
await user.type(catInput, 'Shoes{Enter}');
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
expect(puts).toBe(1);
});
it('FE-ADMIN-PKG-028: a failing category delete toasts and keeps the category', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
http.delete('/api/admin/packing-templates/1/categories/10', () => HttpResponse.error()),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'Clothing');
await user.click(categoryButtons('Clothing')[2]);
expect(await screen.findByText('Failed to delete category')).toBeInTheDocument();
expect(screen.getByText('Clothing')).toBeInTheDocument();
expect(screen.getByText('T-shirt')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-029: the add-item button posts the item, a failing add toasts and X closes the row', async () => {
const user = userEvent.setup();
let posts = 0;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
http.post('/api/admin/packing-templates/1/categories/10/items', () => {
posts += 1;
return posts === 1
? HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Sandals', sort_order: 0 } })
: HttpResponse.json({ error: 'nope' }, { status: 500 });
}),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'Clothing');
await user.click(categoryButtons('Clothing')[0]);
const itemInput = screen.getByPlaceholderText('Item name');
const addRow = itemInput.parentElement as HTMLElement;
expect(within(addRow).getAllByRole('button')[0]).toBeDisabled();
await user.type(itemInput, 'Sandals');
await user.click(within(addRow).getAllByRole('button')[0]);
await screen.findByText('Sandals');
await user.type(screen.getByPlaceholderText('Item name'), 'Towel');
await user.click(within(addRow).getAllByRole('button')[0]);
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
await user.click(within(addRow).getAllByRole('button')[1]);
await waitFor(() => expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument());
});
it('FE-ADMIN-PKG-030: the item editor commits on the check button, cancels on X and ignores a blank name', async () => {
const user = userEvent.setup();
let puts = 0;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
http.put('/api/admin/packing-templates/1/items/100', () => {
puts += 1;
return puts === 1
? HttpResponse.json({ success: true })
: HttpResponse.json({ error: 'nope' }, { status: 500 });
}),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'T-shirt');
// A blank name just closes the editor
await user.click(itemButtons('T-shirt')[0]);
const blank = screen.getByDisplayValue('T-shirt');
await user.clear(blank);
await user.click(within(blank.parentElement as HTMLElement).getAllByRole('button')[0]);
await waitFor(() => expect(screen.getByText('T-shirt')).toBeInTheDocument());
expect(puts).toBe(0);
// X discards the pending name
await user.click(itemButtons('T-shirt')[0]);
const editing = screen.getByDisplayValue('T-shirt');
await user.clear(editing);
await user.type(editing, 'Discarded');
await user.click(within(editing.parentElement as HTMLElement).getAllByRole('button')[1]);
await waitFor(() => expect(screen.getByText('T-shirt')).toBeInTheDocument());
expect(puts).toBe(0);
// The check button commits
await user.click(itemButtons('T-shirt')[0]);
const editing2 = screen.getByDisplayValue('T-shirt');
await user.clear(editing2);
await user.type(editing2, 'Tank Top');
await user.click(within(editing2.parentElement as HTMLElement).getAllByRole('button')[0]);
await screen.findByText('Tank Top');
expect(puts).toBe(1);
// A failing rename keeps the editor open and toasts
await user.click(itemButtons('Tank Top')[0]);
const editing3 = screen.getByDisplayValue('Tank Top');
await user.clear(editing3);
await user.type(editing3, 'Vest{Enter}');
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
expect(screen.getByDisplayValue('Vest')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-031: a failing item delete toasts and keeps the item', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
http.delete('/api/admin/packing-templates/1/items/100', () => HttpResponse.error()),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'T-shirt');
await user.click(itemButtons('T-shirt')[1]);
expect(await screen.findByText('Failed to delete item')).toBeInTheDocument();
expect(screen.getByText('T-shirt')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-032: the chevron button expands and collapses the template', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(templateButtons('Beach Trip')[0]);
await screen.findByText('Clothing');
await user.click(templateButtons('Beach Trip')[0]);
await waitFor(() => expect(screen.queryByText('Clothing')).not.toBeInTheDocument());
});
});
@@ -115,13 +115,12 @@ export default function PackingTemplateManager() {
await adminApi.deleteTemplateCategory(expandedId, catId)
setCategories(prev => prev.filter(c => c.id !== catId))
setItems(prev => prev.filter(i => i.category_id !== catId))
} catch { toast.error(t('admin.packingTemplates.deleteCategoryError')) }
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
}
// Item CRUD
const handleAddItem = async (catId: number) => {
// The name is already guaranteed non-empty by the button and the Enter handler.
if (!expandedId) return
if (!newItemName.trim() || !expandedId) return
try {
const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() })
setItems(prev => [...prev, data.item])
@@ -144,7 +143,7 @@ export default function PackingTemplateManager() {
try {
await adminApi.deleteTemplateItem(expandedId, itemId)
setItems(prev => prev.filter(i => i.id !== itemId))
} catch { toast.error(t('admin.packingTemplates.deleteItemError')) }
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
}
const inputStyle = 'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none'
@@ -1,247 +0,0 @@
// FE-W4BGT-001 to FE-W4BGT-020
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { screen, act, waitFor } from '@testing-library/react'
import { render, fireEvent } from '../../../tests/helpers/render'
import { reservationsApi, healthApi } from '../../api/client'
import { addListener } from '../../api/websocket'
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
import BackgroundTasksWidget from './BackgroundTasksWidget'
const navigate = vi.fn()
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
return { ...actual, useNavigate: () => navigate }
})
vi.mock('../../api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn() }))
vi.mock('../../api/client', () => ({
reservationsApi: { importJobStatus: vi.fn(), importBookingAsync: vi.fn() },
healthApi: { features: vi.fn() },
}))
vi.mock('../../db/offlineDb', () => ({ saveImportFiles: vi.fn(() => Promise.resolve()) }))
const task = (overrides: Partial<BackgroundImportTask> = {}): BackgroundImportTask => ({
id: 'j1', tripId: 't1', label: 'voucher.pdf', status: 'done', done: 0, total: 1, items: [], warnings: [],
...overrides,
})
type WsHandler = (e: Record<string, unknown>) => void
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(healthApi.features).mockReturnValue(new Promise(() => {}))
vi.mocked(reservationsApi.importJobStatus).mockReturnValue(new Promise(() => {}))
useBackgroundTasksStore.setState({ tasks: [] })
})
afterEach(() => {
vi.useRealTimers()
})
describe('BackgroundTasksWidget — rendering', () => {
it('FE-W4BGT-001: renders nothing without tasks', () => {
const { container, baseElement } = render(<BackgroundTasksWidget />)
expect(container).toBeEmptyDOMElement()
expect(baseElement.querySelectorAll('button')).toHaveLength(0)
})
it('FE-W4BGT-002: a running job shows the spinner, the parsing note and no close button', () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', done: 1, total: 3 })] })
const { baseElement } = render(<BackgroundTasksWidget />)
expect(screen.getByText('voucher.pdf')).toBeInTheDocument()
expect(screen.getByText(/· 1\/3$/)).toBeInTheDocument()
expect(baseElement.querySelector('.animate-spin')).not.toBeNull()
expect(screen.queryByLabelText('Close')).toBeNull()
})
it('FE-W4BGT-003: a single-file job omits the counter', () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', done: 0, total: 1 })] })
render(<BackgroundTasksWidget />)
expect(screen.queryByText(/·/)).toBeNull()
})
it('FE-W4BGT-004: a restored done job without items still reads as parsing', () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'done', items: undefined })] })
const { baseElement } = render(<BackgroundTasksWidget />)
expect(baseElement.querySelector('.animate-spin')).not.toBeNull()
expect(screen.getByLabelText('Close')).toBeInTheDocument()
})
it('FE-W4BGT-005: a finished job with items offers the review action', () => {
useBackgroundTasksStore.setState({ tasks: [task({ items: [{ id: 1 }] as never })] })
render(<BackgroundTasksWidget />)
fireEvent.click(screen.getByRole('button', { name: 'Import' }))
expect(useBackgroundTasksStore.getState().tasks[0].reviewRequested).toBe(true)
expect(navigate).toHaveBeenCalledWith('/trips/t1')
})
it('FE-W4BGT-006: a failed job shows the error message', () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'error', error: 'AI quota exhausted' })] })
render(<BackgroundTasksWidget />)
expect(screen.getByText('AI quota exhausted')).toBeInTheDocument()
})
it('FE-W4BGT-007: the close button drops the card', () => {
useBackgroundTasksStore.setState({ tasks: [task()] })
render(<BackgroundTasksWidget />)
fireEvent.click(screen.getByLabelText('Close'))
expect(useBackgroundTasksStore.getState().tasks).toHaveLength(0)
})
})
describe('BackgroundTasksWidget — websocket', () => {
it('FE-W4BGT-008: import:progress updates the running card', () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', total: 4 })] })
render(<BackgroundTasksWidget />)
const handler = vi.mocked(addListener).mock.calls[0][0] as WsHandler
act(() => { handler({ type: 'import:progress', jobId: 'j1', tripId: 't1', done: 2, total: 4 }) })
expect(screen.getByText(/· 2\/4$/)).toBeInTheDocument()
})
it('FE-W4BGT-009: import:done attaches the parsed items', () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
render(<BackgroundTasksWidget />)
const handler = vi.mocked(addListener).mock.calls[0][0] as WsHandler
act(() => { handler({ type: 'import:done', jobId: 'j1', tripId: 't1', result: { items: [{ id: 1 }], warnings: [] } }) })
expect(screen.getByRole('button', { name: 'Import' })).toBeInTheDocument()
})
it('FE-W4BGT-010: import:error surfaces the message', () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running' })] })
render(<BackgroundTasksWidget />)
const handler = vi.mocked(addListener).mock.calls[0][0] as WsHandler
act(() => { handler({ type: 'import:error', jobId: 'j1', tripId: 't1', message: 'boom' }) })
expect(screen.getByText('boom')).toBeInTheDocument()
})
it('FE-W4BGT-011: unrelated events and events without a job id are ignored', () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', total: 4 })] })
render(<BackgroundTasksWidget />)
const handler = vi.mocked(addListener).mock.calls[0][0] as WsHandler
act(() => {
handler({ type: 'place:updated', jobId: 'j1' })
handler({ type: 'import:progress', done: 3, total: 4 })
handler({ done: 3 })
})
expect(screen.getByText(/· 0\/4$/)).toBeInTheDocument()
})
})
describe('BackgroundTasksWidget — rehydrate', () => {
it('FE-W4BGT-012: a restored job that the server finished gets its items back', async () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
vi.mocked(reservationsApi.importJobStatus).mockResolvedValue({
status: 'done', done: 1, total: 1, result: { items: [{ id: 1 }], warnings: [] },
} as never)
render(<BackgroundTasksWidget />)
expect(await screen.findByRole('button', { name: 'Import' })).toBeInTheDocument()
expect(reservationsApi.importJobStatus).toHaveBeenCalledWith('t1', 'j1')
})
it('FE-W4BGT-013: a restored job the server reports as failed shows the error', async () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
vi.mocked(reservationsApi.importJobStatus).mockResolvedValue({ status: 'error', error: 'expired', done: 0, total: 1 } as never)
render(<BackgroundTasksWidget />)
expect(await screen.findByText('expired')).toBeInTheDocument()
})
it('FE-W4BGT-014: a restored job the server has dropped is removed', async () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
vi.mocked(reservationsApi.importJobStatus).mockRejectedValue({ response: { status: 404 } })
render(<BackgroundTasksWidget />)
await waitFor(() => expect(useBackgroundTasksStore.getState().tasks).toHaveLength(0))
})
it('FE-W4BGT-015: a non-404 failure keeps the card', async () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
vi.mocked(reservationsApi.importJobStatus).mockRejectedValue({ response: { status: 500 } })
render(<BackgroundTasksWidget />)
await waitFor(() => expect(reservationsApi.importJobStatus).toHaveBeenCalled())
expect(useBackgroundTasksStore.getState().tasks).toHaveLength(1)
})
})
describe('BackgroundTasksWidget — AI retry', () => {
const withFiles = () => task({
items: [], sourceFiles: [new File(['%PDF'], 'voucher.pdf', { type: 'application/pdf' })],
})
it('FE-W4BGT-016: offers the AI retry only when the feature is on', async () => {
vi.mocked(healthApi.features).mockResolvedValue({ aiParsing: true } as never)
useBackgroundTasksStore.setState({ tasks: [withFiles()] })
render(<BackgroundTasksWidget />)
expect(await screen.findByRole('button', { name: /AI/i })).toBeInTheDocument()
})
it('FE-W4BGT-017: hides the retry when the feature probe fails', async () => {
vi.mocked(healthApi.features).mockRejectedValue(new Error('down'))
useBackgroundTasksStore.setState({ tasks: [withFiles()] })
render(<BackgroundTasksWidget />)
await waitFor(() => expect(healthApi.features).toHaveBeenCalled())
expect(screen.queryByRole('button', { name: /AI/i })).toBeNull()
})
it('FE-W4BGT-018: hides the retry on a job that already ran with force-ai', async () => {
vi.mocked(healthApi.features).mockResolvedValue({ aiParsing: true } as never)
useBackgroundTasksStore.setState({ tasks: [task({ items: [], mode: 'force-ai', sourceFiles: [new File([''], 'a.pdf')] })] })
render(<BackgroundTasksWidget />)
await waitFor(() => expect(healthApi.features).toHaveBeenCalled())
expect(screen.queryByRole('button', { name: /AI/i })).toBeNull()
})
it('FE-W4BGT-019: retrying swaps the card for the new force-ai job', async () => {
vi.mocked(healthApi.features).mockResolvedValue({ aiParsing: true } as never)
vi.mocked(reservationsApi.importBookingAsync).mockResolvedValue({ jobId: 'j2' } as never)
useBackgroundTasksStore.setState({ tasks: [withFiles()] })
render(<BackgroundTasksWidget />)
fireEvent.click(await screen.findByRole('button', { name: /AI/i }))
await waitFor(() => {
const tasks = useBackgroundTasksStore.getState().tasks
expect(tasks).toHaveLength(1)
expect(tasks[0]).toMatchObject({ id: 'j2', mode: 'force-ai', tripId: 't1' })
})
})
it('FE-W4BGT-020: a refused retry surfaces the server error on the original card', async () => {
vi.mocked(healthApi.features).mockResolvedValue({ aiParsing: true } as never)
vi.mocked(reservationsApi.importBookingAsync).mockRejectedValue({ response: { data: { error: 'No model configured' } } })
useBackgroundTasksStore.setState({ tasks: [withFiles()] })
render(<BackgroundTasksWidget />)
fireEvent.click(await screen.findByRole('button', { name: /AI/i }))
expect(await screen.findByText('No model configured')).toBeInTheDocument()
})
})
@@ -1,136 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '../../../tests/helpers/render'
import { reservationsApi, healthApi } from '../../api/client'
import { saveImportFiles } from '../../db/offlineDb'
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
import BackgroundTasksWidget from './BackgroundTasksWidget'
vi.mock('../../api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn() }))
vi.mock('../../api/client', () => ({
// Keep the rehydrate/poll backstops pending so the seeded state is what renders.
reservationsApi: { importJobStatus: vi.fn(() => new Promise(() => {})), importBookingAsync: vi.fn() },
healthApi: { features: vi.fn() },
}))
vi.mock('../../db/offlineDb', () => ({ saveImportFiles: vi.fn(() => Promise.resolve()) }))
const task = (overrides: Partial<BackgroundImportTask> = {}): BackgroundImportTask => ({
id: 'j1',
tripId: 't1',
label: 'voucher.pdf',
status: 'done',
done: 0,
total: 1,
items: [],
warnings: [],
...overrides,
})
const pdf = () => new File(['%PDF'], 'voucher.pdf', { type: 'application/pdf' })
beforeEach(() => {
vi.clearAllMocks()
// Like the poll backstop above: leave the feature probe pending so tests that don't care
// about the AI retry render the same widget they did before the button existed.
vi.mocked(healthApi.features).mockReturnValue(new Promise(() => {}))
vi.mocked(saveImportFiles).mockResolvedValue(undefined)
useBackgroundTasksStore.setState({ tasks: [] })
})
describe('BackgroundTasksWidget', () => {
it('shows the warnings when a finished job produced no items', () => {
const warning = 'voucher.pdf: AI parsing failed — LLM request failed (400): response_format unsupported'
useBackgroundTasksStore.setState({ tasks: [task({ warnings: [warning] })] })
render(<BackgroundTasksWidget />)
expect(screen.getByText('No reservations could be extracted from the uploaded files.')).toBeInTheDocument()
expect(screen.getByText(warning)).toBeInTheDocument()
})
it('shows only the empty-preview note when there are no warnings', () => {
useBackgroundTasksStore.setState({ tasks: [task()] })
render(<BackgroundTasksWidget />)
expect(screen.getByText('No reservations could be extracted from the uploaded files.')).toBeInTheDocument()
expect(screen.queryByText(/AI parsing failed/)).not.toBeInTheDocument()
})
describe('AI retry', () => {
it('offers the retry on an empty result once the addon reports AI parsing', async () => {
vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true })
useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [pdf()] })] })
render(<BackgroundTasksWidget />)
expect(await screen.findByRole('button', { name: 'Try AI parsing' })).toBeInTheDocument()
})
it('stays hidden when the addon is off, the files are gone, or the run was already force-ai', async () => {
vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: false })
useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [pdf()] })] })
const { unmount } = render(<BackgroundTasksWidget />)
await waitFor(() => expect(healthApi.features).toHaveBeenCalled())
expect(screen.queryByRole('button', { name: 'Try AI parsing' })).not.toBeInTheDocument()
unmount()
vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true })
// Rehydrated from storage: sourceFiles can't survive a reload, so there is nothing to resend.
useBackgroundTasksStore.setState({ tasks: [task()] })
const withoutFiles = render(<BackgroundTasksWidget />)
await waitFor(() => expect(healthApi.features).toHaveBeenCalledTimes(2))
expect(screen.queryByRole('button', { name: 'Try AI parsing' })).not.toBeInTheDocument()
withoutFiles.unmount()
useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [pdf()], mode: 'force-ai' })] })
render(<BackgroundTasksWidget />)
await waitFor(() => expect(healthApi.features).toHaveBeenCalledTimes(3))
expect(screen.queryByRole('button', { name: 'Try AI parsing' })).not.toBeInTheDocument()
})
it('re-submits the files with force-ai, keeps them for the review and replaces the task', async () => {
const file = pdf()
vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true })
vi.mocked(reservationsApi.importBookingAsync).mockResolvedValue({ jobId: 'j2' })
useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [file] })] })
render(<BackgroundTasksWidget />)
await userEvent.click(await screen.findByRole('button', { name: 'Try AI parsing' }))
expect(reservationsApi.importBookingAsync).toHaveBeenCalledWith('t1', [file], 'force-ai')
// Without this the reviewed bookings lose their source document after a reload.
await waitFor(() => expect(saveImportFiles).toHaveBeenCalledWith('j2', [file]))
await waitFor(() => {
const tasks = useBackgroundTasksStore.getState().tasks
expect(tasks).toHaveLength(1)
expect(tasks[0]).toMatchObject({ id: 'j2', tripId: 't1', status: 'running', mode: 'force-ai' })
})
})
it('keeps the task and surfaces the server error when the retry is rejected', async () => {
vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true })
vi.mocked(reservationsApi.importBookingAsync).mockRejectedValue({ response: { data: { error: 'No AI model configured' } } })
useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [pdf()] })] })
render(<BackgroundTasksWidget />)
await userEvent.click(await screen.findByRole('button', { name: 'Try AI parsing' }))
expect(await screen.findByText('No AI model configured')).toBeInTheDocument()
const tasks = useBackgroundTasksStore.getState().tasks
expect(tasks).toHaveLength(1)
expect(tasks[0]).toMatchObject({ id: 'j1', status: 'error' })
expect(saveImportFiles).not.toHaveBeenCalled()
})
it('ignores a second click while the first retry is still in flight', async () => {
vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true })
// Never settles: the retry stays in flight for the whole test.
vi.mocked(reservationsApi.importBookingAsync).mockReturnValue(new Promise<{ jobId: string }>(() => {}))
useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [pdf()] })] })
render(<BackgroundTasksWidget />)
const button = await screen.findByRole('button', { name: 'Try AI parsing' })
await userEvent.click(button)
await waitFor(() => expect(button).toBeDisabled())
await userEvent.click(button)
expect(reservationsApi.importBookingAsync).toHaveBeenCalledTimes(1)
})
})
})
@@ -1,211 +0,0 @@
import ReactDOM from 'react-dom'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { addListener, removeListener } from '../../api/websocket'
import { reservationsApi, healthApi } from '../../api/client'
import { saveImportFiles } from '../../db/offlineDb'
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
/**
* Global, route-independent widget (bottom-right) that tracks background booking
* imports. Mounted once at the app root so it survives navigation. It listens to the
* user's WebSocket for import:progress / import:done / import:error and reflects each
* job; a finished job offers a "review" action that takes the user to the trip, where
* the per-item review flow opens. Polls running jobs as a backstop for missed pushes.
*/
export default function BackgroundTasksWidget() {
const { t } = useTranslation()
const navigate = useNavigate()
const tasks = useBackgroundTasksStore((s) => s.tasks)
const setProgress = useBackgroundTasksStore((s) => s.setProgress)
const setDone = useBackgroundTasksStore((s) => s.setDone)
const setError = useBackgroundTasksStore((s) => s.setError)
const requestReview = useBackgroundTasksStore((s) => s.requestReview)
const dismiss = useBackgroundTasksStore((s) => s.dismiss)
const addTask = useBackgroundTasksStore((s) => s.addTask)
const [aiParsing, setAiParsing] = useState(false)
useEffect(() => {
healthApi.features().then((f) => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false))
}, [])
// Re-runs the same files with force-ai: the LLM sees every file, kitinerary is skipped.
const [retrying, setRetrying] = useState<string | null>(null)
const retryWithAi = async (task: BackgroundImportTask) => {
const files = task.sourceFiles
if (!files || files.length === 0 || retrying === task.id) return
setRetrying(task.id)
try {
const { jobId } = await reservationsApi.importBookingAsync(task.tripId, files, 'force-ai')
// Same as the modal's first submit: the review attaches each source document to the
// booking it created, and only IndexedDB survives a reload mid-parse.
await saveImportFiles(jobId, files)
dismiss(task.id)
addTask({ id: jobId, tripId: task.tripId, label: task.label, total: files.length, files, mode: 'force-ai' })
} catch (err) {
// 409 when the addon is enabled but this user has no model configured.
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error
setError(task.id, task.tripId, message ?? t('reservations.import.error'))
} finally {
setRetrying(null)
}
}
// On (re)load, reconcile tasks restored from localStorage with the server: a parse
// that was still running when the page reloaded must keep its widget, so re-fetch each
// job's real status (and its parsed items) once. A job the server has since dropped
// (404, expired) is removed so no stale card lingers.
const didRehydrate = useRef(false)
useEffect(() => {
if (didRehydrate.current) return
didRehydrate.current = true
const restored = useBackgroundTasksStore.getState().tasks
for (const task of restored) {
reservationsApi
.importJobStatus(task.tripId, task.id)
.then((s) => {
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
else setProgress(task.id, task.tripId, s.done, s.total)
})
.catch((err: { response?: { status?: number } }) => {
if (err?.response?.status === 404) dismiss(task.id)
})
}
// run once on mount against whatever was rehydrated from storage
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Server pushes import:* to the user on whatever page they're on.
useEffect(() => {
const handler = (e: Record<string, unknown>) => {
const type = typeof e.type === 'string' ? e.type : ''
if (!type.startsWith('import:')) return
const id = String(e.jobId ?? '')
const tripId = String(e.tripId ?? '')
if (!id) return
if (type === 'import:progress') setProgress(id, tripId, Number(e.done ?? 0), Number(e.total ?? 1))
else if (type === 'import:done') {
const result = e.result as { items?: unknown[]; warnings?: string[] } | undefined
setDone(id, tripId, (result?.items ?? []) as never, result?.warnings ?? [])
} else if (type === 'import:error') setError(id, tripId, String(e.message ?? 'error'))
}
addListener(handler)
return () => removeListener(handler)
}, [setProgress, setDone, setError])
// Backstop: poll jobs whose state we still need — running ones (in case a WebSocket push
// was missed) and a restored 'done' task whose items haven't been re-fetched yet (so a
// failed one-shot rehydrate self-heals instead of getting stuck on "preview empty").
useEffect(() => {
const pending = tasks.filter((task) => task.status === 'running' || (task.status === 'done' && task.items === undefined))
if (pending.length === 0) return
const iv = setInterval(() => {
for (const task of pending) {
reservationsApi
.importJobStatus(task.tripId, task.id)
.then((s) => {
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
else setProgress(task.id, task.tripId, s.done, s.total)
})
.catch(() => {})
}
}, 5000)
return () => clearInterval(iv)
}, [tasks, setProgress, setDone, setError])
if (tasks.length === 0) return null
const review = (task: BackgroundImportTask) => {
requestReview(task.id)
navigate(`/trips/${task.tripId}`)
}
return ReactDOM.createPortal(
<div
style={{ position: 'fixed', right: 16, bottom: 16, zIndex: 50000, display: 'flex', flexDirection: 'column', gap: 8, width: 380, maxWidth: 'calc(100vw - 32px)', fontFamily: 'var(--font-system)' }}
>
{tasks.map((task) => (
<div
key={task.id}
className="bg-surface-card"
style={{ borderRadius: 12, border: '1px solid var(--border-primary)', boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '11px 13px', backdropFilter: 'blur(8px)', display: 'flex', gap: 10, alignItems: 'flex-start' }}
>
<div style={{ flexShrink: 0, marginTop: 1 }}>
{(task.status === 'running' || (task.status === 'done' && task.items === undefined)) && <Loader2 size={16} className="animate-spin" color="var(--accent)" />}
{task.status === 'done' && task.items !== undefined && <CheckCircle2 size={16} color="#10b981" />}
{task.status === 'error' && <AlertCircle size={16} color="#ef4444" />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.label}
</div>
{task.status === 'running' && (
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>
{t('reservations.import.parsing')}
{task.total > 1 ? ` · ${task.done}/${task.total}` : ''}
</div>
)}
{task.status === 'done' && (
task.items === undefined ? (
// Restored from a reload; items are being re-fetched (see the poll backstop).
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.parsing')}</div>
) : task.items.length > 0 ? (
<button
onClick={() => review(task)}
className="bg-accent text-accent-text"
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.import')}
</button>
) : (
<div>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>
{t('reservations.import.previewEmpty')}
{(task.warnings?.length ?? 0) > 0 && (
<div style={{ color: '#b45309', marginTop: 3, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 96, overflowY: 'auto' }}>
{task.warnings!.join('\n')}
</div>
)}
</div>
{aiParsing && task.mode !== 'force-ai' && task.sourceFiles && task.sourceFiles.length > 0 && (
<button
onClick={() => retryWithAi(task)}
disabled={retrying === task.id}
className="bg-surface-tertiary text-content"
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: retrying === task.id ? 'default' : 'pointer', opacity: retrying === task.id ? 0.6 : 1, fontFamily: 'inherit' }}
>
{t('reservations.import.tryAi')}
</button>
)}
</div>
)
)}
{task.status === 'error' && (
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#b91c1c', marginTop: 1, whiteSpace: 'pre-wrap' }}>{task.error}</div>
)}
</div>
{task.status !== 'running' && (
<button
onClick={() => dismiss(task.id)}
className="bg-transparent text-content-faint"
style={{ flexShrink: 0, border: 'none', cursor: 'pointer', padding: 2, borderRadius: 6, display: 'flex', alignItems: 'center' }}
aria-label={t('common.close')}
>
<X size={13} />
</button>
)}
</div>
))}
</div>,
document.body
)
}
@@ -1,78 +0,0 @@
// The full set of currencies the Frankfurter v2 FX API supports (archived codes
// excluded), so every selectable currency actually converts. Regenerate from
// `GET https://api.frankfurter.dev/v2/currencies?expand=providers` (iso_code +
// symbol) if the provider's list changes. See issue #1470.
export const CURRENCIES = [
'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN',
'BAM', 'BBD', 'BDT', 'BHD', 'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD',
'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLP', 'CNH', 'CNY',
'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP',
'ERN', 'ETB', 'EUR', 'FJD', 'FKP', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP',
'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HTG', 'HUF', 'IDR', 'ILS',
'IMP', 'INR', 'IQD', 'IRR', 'ISK', 'JEP', 'JMD', 'JOD', 'JPY', 'KES',
'KGS', 'KHR', 'KMF', 'KPW', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP',
'LKR', 'LRD', 'LSL', 'LYD', 'MAD', 'MDL', 'MGA', 'MKD', 'MMK', 'MNT',
'MOP', 'MRO', 'MRU', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD',
'NGN', 'NIO', 'NOK', 'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP',
'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF', 'SAR', 'SBD',
'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLE', 'SOS', 'SRD', 'SSP', 'STN',
'SVC', 'SYP', 'SZL', 'THB', 'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD',
'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VES', 'VND', 'VUV',
'WST', 'XAF', 'XAG', 'XAU', 'XCD', 'XCG', 'XDR', 'XOF', 'XPD', 'XPF',
'XPT', 'YER', 'ZAR', 'ZMW', 'ZWG',
]
export const SYMBOLS: Record<string, string> = {
AED: 'د.إ', AFN: '؋', ALL: 'L', AMD: '֏', ANG: 'ƒ',
AOA: 'Kz', ARS: '$', AUD: '$', AWG: 'ƒ', AZN: '₼',
BAM: 'КМ', BBD: '$', BDT: '৳', BHD: 'د.ب', BIF: 'Fr',
BMD: '$', BND: '$', BOB: 'Bs.', BRL: 'R$', BSD: '$',
BTN: 'Nu.', BWP: 'P', BYN: 'Br', BZD: '$', CAD: '$',
CDF: 'Fr', CHF: 'CHF', CLP: '$', CNH: '¥', CNY: '¥',
COP: '$', CRC: '₡', CUP: '$', CVE: '$', CZK: 'Kč',
DJF: 'Fdj', DKK: 'kr.', DOP: '$', DZD: 'د.ج', EGP: 'ج.م',
ERN: 'Nfk', ETB: 'Br', EUR: '€', FJD: '$', FKP: '£',
GBP: '£', GEL: '₾', GGP: '£', GHS: '₵', GIP: '£',
GMD: 'D', GNF: 'Fr', GTQ: 'Q', GYD: '$', HKD: '$',
HNL: 'L', HTG: 'G', HUF: 'Ft', IDR: 'Rp', ILS: '₪',
IMP: '£', INR: '₹', IQD: 'ع.د', IRR: '﷼', ISK: 'kr.',
JEP: '£', JMD: '$', JOD: 'د.ا', JPY: '¥', KES: 'KSh',
KGS: 'som', KHR: '៛', KMF: 'Fr', KPW: '₩', KRW: '₩',
KWD: 'د.ك', KYD: '$', KZT: '₸', LAK: '₭', LBP: 'ل.ل',
LKR: '₨', LRD: '$', LSL: 'L', LYD: 'ل.د', MAD: 'د.م.',
MDL: 'L', MGA: 'Ar', MKD: 'ден', MMK: 'K', MNT: '₮',
MOP: 'P', MRO: 'UM', MRU: 'UM', MUR: '₨', MVR: 'MVR',
MWK: 'MK', MXN: '$', MYR: 'RM', MZN: 'MTn', NAD: '$',
NGN: '₦', NIO: 'C$', NOK: 'kr', NPR: 'Rs.', NZD: '$',
OMR: 'ر.ع.', PAB: 'B/.', PEN: 'S/', PGK: 'K', PHP: '₱',
PKR: '₨', PLN: 'zł', PYG: '₲', QAR: 'ر.ق', RON: 'Lei',
RSD: 'RSD', RUB: '₽', RWF: 'FRw', SAR: 'ر.س', SBD: '$',
SCR: '₨', SDG: '£', SEK: 'kr', SGD: '$', SHP: '£',
SLE: 'Le', SOS: 'Sh', SRD: '$', SSP: '£', STN: 'Db',
SVC: '₡', SYP: '£S', SZL: 'E', THB: '฿', TJS: 'ЅМ',
TMT: 'm', TND: 'د.ت', TOP: 'T$', TRY: '₺', TTD: '$',
TWD: '$', TZS: 'Sh', UAH: '₴', UGX: 'USh', USD: '$',
UYU: '$U', UZS: 'so\'m', VES: 'Bs', VND: '₫', VUV: 'Vt',
WST: 'T', XAF: 'CFA', XAG: 'oz t', XAU: 'oz t', XCD: '$',
XCG: 'Cg', XDR: 'SDR', XOF: 'Fr', XPD: 'oz t', XPF: 'Fr',
XPT: 'oz t', YER: '﷼', ZAR: 'R', ZMW: 'K', ZWG: 'ZiG',
}
// Keep a currency the user already saved selectable even after it leaves the
// supported set (e.g. archived BGN/HRK), so opening an existing item or settings
// row doesn't silently blank the field and wipe the value on the next save.
export function currenciesWith(current?: string | null): readonly string[] {
const cur = (current || '').toUpperCase()
return cur && !CURRENCIES.includes(cur) ? [...CURRENCIES, cur] : CURRENCIES
}
export const PIE_COLORS =['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
export const SPLIT_COLORS = [
{ solid: '#6366f1', gradient: 'linear-gradient(135deg, #6366f1, #8b5cf6)' },
{ solid: '#ec4899', gradient: 'linear-gradient(135deg, #ec4899, #f43f5e)' },
{ solid: '#10b981', gradient: 'linear-gradient(135deg, #10b981, #22c55e)' },
{ solid: '#f59e0b', gradient: 'linear-gradient(135deg, #f59e0b, #f97316)' },
{ solid: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4, #3b82f6)' },
{ solid: '#a855f7', gradient: 'linear-gradient(135deg, #a855f7, #d946ef)' },
]
@@ -1,42 +0,0 @@
import { describe, it, expect } from 'vitest'
import { calcPP, hasCustomMemberSplit, normalizePastedAmount } from './BudgetPanel.helpers'
describe('BudgetPanel.helpers', () => {
describe('hasCustomMemberSplit (#1458)', () => {
it('is false when no members', () => {
expect(hasCustomMemberSplit({})).toBe(false)
expect(hasCustomMemberSplit({ members: [] })).toBe(false)
})
it('is false for an equal split (members carry no amount)', () => {
expect(hasCustomMemberSplit({ members: [{ amount: null }, { amount: null }] })).toBe(false)
expect(hasCustomMemberSplit({ members: [{}, {}] })).toBe(false)
})
it('is true as soon as any member has a custom amount', () => {
expect(hasCustomMemberSplit({ members: [{ amount: 90 }, { amount: 10 }] })).toBe(true)
expect(hasCustomMemberSplit({ members: [{ amount: null }, { amount: 10 }] })).toBe(true)
expect(hasCustomMemberSplit({ members: [{ amount: 0 }] })).toBe(true)
})
})
it('calcPP still averages the total for equal splits', () => {
expect(calcPP(100, 2)).toBe(50)
expect(calcPP(100, 0)).toBeNull()
expect(calcPP(100, null)).toBeNull()
})
describe('normalizePastedAmount', () => {
it('keeps the last separator as the decimal point', () => {
expect(normalizePastedAmount('1.234,56 €')).toBe('1234.56')
expect(normalizePastedAmount('$1,234.56')).toBe('1234.56')
expect(normalizePastedAmount(' -12,5 ')).toBe('-12.5')
})
it('drops everything that is not part of the number', () => {
expect(normalizePastedAmount('EUR 1 234 567')).toBe('1234567')
expect(normalizePastedAmount('42')).toBe('42')
expect(normalizePastedAmount('abc')).toBe('')
})
})
})
@@ -1,91 +0,0 @@
import { currencyDecimals } from '../../utils/formatters'
import { SYMBOLS, SPLIT_COLORS } from './BudgetPanel.constants'
export function widgetTheme(dark: boolean) {
if (dark) return {
bg: 'linear-gradient(180deg, #17171d 0%, #0d0d12 100%)',
border: 'rgba(255,255,255,0.07)',
text: '#ffffff',
sub: 'rgba(255,255,255,0.6)',
faint: 'rgba(255,255,255,0.4)',
track: 'rgba(255,255,255,0.04)',
divider: 'rgba(255,255,255,0.07)',
iconBg: 'rgba(255,255,255,0.08)',
iconBorder: 'rgba(255,255,255,0.12)',
iconColor: 'rgba(255,255,255,0.9)',
centerBg: '#17171d',
flowBg: 'rgba(255,255,255,0.05)',
flowBorder: 'rgba(255,255,255,0.07)',
flowHoverBg: 'rgba(255,255,255,0.08)',
flowHoverBorder: 'rgba(255,255,255,0.12)',
rowHover: 'rgba(255,255,255,0.03)',
shadow: '0 20px 50px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.04)',
donutShadow: 'drop-shadow(0 0 20px rgba(0,0,0,0.3))',
}
return {
bg: 'linear-gradient(180deg, #ffffff 0%, #f9fafb 100%)',
border: 'rgba(15,23,42,0.08)',
text: '#111827',
sub: 'rgba(17,24,39,0.6)',
faint: 'rgba(17,24,39,0.4)',
track: 'rgba(15,23,42,0.05)',
divider: 'rgba(15,23,42,0.08)',
iconBg: 'rgba(15,23,42,0.05)',
iconBorder: 'rgba(15,23,42,0.1)',
iconColor: 'rgba(17,24,39,0.75)',
centerBg: '#ffffff',
flowBg: 'rgba(15,23,42,0.03)',
flowBorder: 'rgba(15,23,42,0.08)',
flowHoverBg: 'rgba(15,23,42,0.06)',
flowHoverBorder: 'rgba(15,23,42,0.14)',
rowHover: 'rgba(15,23,42,0.04)',
shadow: '0 12px 32px rgba(15,23,42,0.08), 0 2px 6px rgba(0,0,0,0.04)',
donutShadow: 'drop-shadow(0 4px 18px rgba(15,23,42,0.12))',
}
}
export function hexLighten(hex: string, amount: number): string {
const m = hex.replace('#', '').match(/.{2}/g)
if (!m || m.length !== 3) return hex
const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount))
const [r, g, b] = m.map(x => parseInt(x, 16))
return `#${[mix(r), mix(g), mix(b)].map(v => v.toString(16).padStart(2, '0')).join('')}`
}
export const fmtNum = (v: number | null | undefined, locale: string, cur: string) => {
if (v == null || isNaN(v)) return '-'
const d = currencyDecimals(cur)
return Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) + ' ' + (SYMBOLS[cur] || cur)
}
type NumOrNull = number | null | undefined
export const calcPP = (p: NumOrNull, n: NumOrNull) => (n! > 0 ? (p as number) / (n as number) : null)
export const calcPD = (p: NumOrNull, d: NumOrNull) => (d! > 0 ? (p as number) / (d as number) : null)
export const calcPPD = (p: NumOrNull, n: NumOrNull, d: NumOrNull) => (n! > 0 && d! > 0 ? (p as number) / ((n as number) * (d as number)) : null)
// A custom (uneven) split has no single "per person" figure — one member's share
// differs from another's — so the averaged per-person columns are meaningless for it
// (the per-member amounts are shown via the member chips instead). #1458
export const hasCustomMemberSplit = (item: { members?: { amount?: number | null }[] }) =>
(item.members || []).some(m => m.amount != null)
export function splitColorFor(userId: number, order: number) {
return SPLIT_COLORS[order % SPLIT_COLORS.length]
}
export function colorForUserId(userId: number) {
return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
}
/**
* Normalises a pasted amount to a plain `1234.56` string: drops currency
* symbols and spaces, treats the last comma/dot as the decimal separator and
* removes every thousand separator before it.
*/
export function normalizePastedAmount(raw: string): string {
const text = raw.trim().replace(/[^\d.,-]/g, '')
const decimalPos = Math.max(text.lastIndexOf(','), text.lastIndexOf('.'))
if (decimalPos === -1) return text.replace(/[.,]/g, '')
return text.substring(0, decimalPos).replace(/[.,]/g, '') + '.' + text.substring(decimalPos + 1)
}
@@ -66,8 +66,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
// 'Transport' appears in the category section header and the spend breakdown chart.
expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0);
await screen.findByText('Transport');
});
it('FE-COMP-BUDGET-006: renders budget table headers', async () => {
@@ -77,8 +76,7 @@ describe('BudgetPanel', () => {
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Name');
// 'Total' appears both as a table header and in the chart total label.
expect((await screen.findAllByText('Total')).length).toBeGreaterThan(0);
await screen.findByText('Total');
});
it('FE-COMP-BUDGET-007: shows Budget title heading', async () => {
@@ -171,9 +169,8 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
);
render(<BudgetPanel tripId={1} />);
// Each category appears in its section header and again in the breakdown chart.
expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0);
expect((await screen.findAllByText('Hotels')).length).toBeGreaterThan(0);
await screen.findByText('Transport');
await screen.findByText('Hotels');
});
it('FE-COMP-BUDGET-015: currency from settings store is used for default_currency display', async () => {
@@ -203,8 +200,7 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
// 'ToDelete' appears in the category header and the breakdown chart.
expect((await screen.findAllByText('ToDelete')).length).toBeGreaterThan(0);
await screen.findByText('ToDelete');
expect(screen.getByTitle('Delete Category')).toBeInTheDocument();
});
@@ -394,7 +390,7 @@ describe('BudgetPanel', () => {
const item = {
...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Shared Dinner' }),
total_price: 75,
members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: 0 }],
members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: false }],
};
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
@@ -429,7 +425,7 @@ describe('BudgetPanel', () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
// Use a user with id != 1 so they're not the owner
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
@@ -443,7 +439,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-034: read-only mode shows expense_date as text span', async () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
@@ -488,7 +484,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-036: expense_date shows dash when not set in read-only mode', async () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
File diff suppressed because it is too large Load Diff
@@ -1,119 +0,0 @@
// FE-W4AIR-001 to FE-W4AIR-009
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import AddItemRow from './BudgetPanelAddItemRow'
const t = (key: string) => key
function setup() {
const onAdd = vi.fn()
const utils = render(<table><tbody><AddItemRow onAdd={onAdd} t={t} /></tbody></table>)
return { onAdd, ...utils }
}
const nameInput = () => screen.getByPlaceholderText('budget.newEntry')
const priceInput = () => screen.getByPlaceholderText('0,00')
const noteInput = () => screen.getByPlaceholderText('budget.table.note')
const numberInputs = () => screen.getAllByPlaceholderText('-')
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
describe('BudgetPanelAddItemRow', () => {
it('FE-W4AIR-001: the add button stays disabled until a name is typed', () => {
setup()
const button = screen.getByRole('button', { name: 'reservations.add' })
expect(button).toBeDisabled()
fireEvent.change(nameInput(), { target: { value: 'Ferry' } })
expect(button).toBeEnabled()
})
it('FE-W4AIR-002: submits the trimmed name with parsed numbers', () => {
const { onAdd } = setup()
fireEvent.change(nameInput(), { target: { value: ' Ferry ' } })
fireEvent.change(priceInput(), { target: { value: '129,90' } })
fireEvent.change(numberInputs()[0], { target: { value: '2' } })
fireEvent.change(numberInputs()[1], { target: { value: '3' } })
fireEvent.change(noteInput(), { target: { value: ' one way ' } })
fireEvent.click(screen.getByRole('button', { name: 'reservations.add' }))
expect(onAdd).toHaveBeenCalledWith({
name: 'Ferry', total_price: 129.9, persons: 2, days: 3, note: 'one way', expense_date: null,
})
})
it('FE-W4AIR-003: falls back to zero price and null optionals', () => {
const { onAdd } = setup()
fireEvent.change(nameInput(), { target: { value: 'Ferry' } })
fireEvent.click(screen.getByRole('button', { name: 'reservations.add' }))
expect(onAdd).toHaveBeenCalledWith({
name: 'Ferry', total_price: 0, persons: null, days: null, note: null, expense_date: null,
})
})
it('FE-W4AIR-004: ignores a whitespace-only name', () => {
const { onAdd } = setup()
fireEvent.change(nameInput(), { target: { value: ' ' } })
fireEvent.keyDown(nameInput(), { key: 'Enter' })
expect(onAdd).not.toHaveBeenCalled()
})
it('FE-W4AIR-005: Enter in any field submits the row', () => {
const { onAdd } = setup()
fireEvent.change(nameInput(), { target: { value: 'Ferry' } })
fireEvent.keyDown(priceInput(), { key: 'Enter' })
expect(onAdd).toHaveBeenCalledTimes(1)
})
it('FE-W4AIR-006: a non-Enter key does not submit', () => {
const { onAdd } = setup()
fireEvent.change(nameInput(), { target: { value: 'Ferry' } })
fireEvent.keyDown(nameInput(), { key: 'a' })
expect(onAdd).not.toHaveBeenCalled()
})
it('FE-W4AIR-007: clears the row and refocuses the name field after adding', () => {
setup()
fireEvent.change(nameInput(), { target: { value: 'Ferry' } })
fireEvent.change(priceInput(), { target: { value: '12' } })
fireEvent.click(screen.getByRole('button', { name: 'reservations.add' }))
expect(nameInput()).toHaveValue('')
expect(priceInput()).toHaveValue('')
vi.advanceTimersByTime(60)
expect(nameInput()).toHaveFocus()
})
it('FE-W4AIR-008: pasting a formatted amount normalizes the separators', () => {
setup()
fireEvent.paste(priceInput(), { clipboardData: { getData: () => '1.234,56 EUR' } })
expect(priceInput()).toHaveValue('1234.56')
fireEvent.paste(priceInput(), { clipboardData: { getData: () => '$2,345.67' } })
expect(priceInput()).toHaveValue('2345.67')
})
it('FE-W4AIR-009: pasting a separator-free amount keeps the digits', () => {
setup()
fireEvent.paste(priceInput(), { clipboardData: { getData: () => 'EUR 4200' } })
expect(priceInput()).toHaveValue('4200')
})
})

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