mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bfe57d75c | |||
| 7ca7dfd555 | |||
| c708b93824 | |||
| b50812d51d | |||
| 267e0e16a2 |
@@ -1,6 +0,0 @@
|
||||
# Commits that only reformat code. git blame skips them with
|
||||
# git config blame.ignoreRevsFile .git-blame-ignore-revs
|
||||
# (GitHub reads this file on its own).
|
||||
|
||||
# style: format server and shared with prettier
|
||||
724c88c201b2c16d31317eaea5f59f43d314b833
|
||||
@@ -1,71 +0,0 @@
|
||||
# Version updates, weekly, into dev. Minor and patch bumps of one ecosystem
|
||||
# arrive as a single grouped PR; a major bump gets a PR of its own so its
|
||||
# breaking notes are read on their own.
|
||||
#
|
||||
# Security updates are a repository setting, not part of this file, and
|
||||
# GitHub opens them against the default branch whatever target-branch says.
|
||||
# enforce-target-branch.yml moves those onto dev.
|
||||
#
|
||||
# Every PR runs test.yml like any other, so ci-ok decides whether it can
|
||||
# merge. Dependabot runs get no Actions secrets, so s3-contract and the Sonar
|
||||
# scan skip themselves on these PRs and ci-ok counts the skip as passed.
|
||||
version: 2
|
||||
updates:
|
||||
# The root lockfile covers the client, server and shared workspaces.
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
prefix-development: chore(deps-dev)
|
||||
groups:
|
||||
npm-minor-and-patch:
|
||||
update-types: [minor, patch]
|
||||
|
||||
# plugin-sdk is standalone with a lockfile of its own and ships to npm.
|
||||
- package-ecosystem: npm
|
||||
directory: /plugin-sdk
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 3
|
||||
commit-message:
|
||||
prefix: chore(plugin-sdk)
|
||||
prefix-development: chore(plugin-sdk)
|
||||
groups:
|
||||
plugin-sdk-minor-and-patch:
|
||||
update-types: [minor, patch]
|
||||
|
||||
# Keeps the SHA pins of third-party actions current, comment included.
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 3
|
||||
commit-message:
|
||||
prefix: ci
|
||||
groups:
|
||||
actions-minor-and-patch:
|
||||
update-types: [minor, patch]
|
||||
|
||||
# The Dockerfile's base images. The Node major follows .nvmrc and the
|
||||
# engines ranges, so it moves by hand, together with them.
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 3
|
||||
commit-message:
|
||||
prefix: chore(docker)
|
||||
ignore:
|
||||
- dependency-name: node
|
||||
update-types: [version-update:semver-major]
|
||||
@@ -10,11 +10,10 @@ permissions:
|
||||
jobs:
|
||||
close-stale:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close stale invalid-title issues
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const badTitles = [
|
||||
|
||||
@@ -11,11 +11,10 @@ permissions:
|
||||
jobs:
|
||||
close-stale:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close stale wrong-base-branch PRs
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data: pulls } = await github.rest.pulls.list({
|
||||
|
||||
@@ -10,11 +10,10 @@ permissions:
|
||||
jobs:
|
||||
check-title:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Flag or redirect issue
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const title = context.payload.issue.title.trim();
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
name: Build & Push Docker Image (Prerelease)
|
||||
|
||||
# The canary. Dispatched by hand, usually on dev, it builds the dispatched
|
||||
# commit as <next minor or major>-pre.N, publishes :VERSION, :MAJOR-pre and
|
||||
# :latest-pre, and tags the commit. Nothing moves :latest. Like the stable
|
||||
# release it refuses a commit whose ci-ok is not green. docker.yml with
|
||||
# bump=auto later finalizes the line once main carries it.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -17,7 +11,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
checks: read
|
||||
|
||||
concurrency:
|
||||
group: prerelease-build
|
||||
@@ -26,41 +19,23 @@ concurrency:
|
||||
jobs:
|
||||
version-bump:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.VERSION }}
|
||||
sha: ${{ steps.bump.outputs.SHA }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# The dev push, the main push and any manual run each leave a ci-ok on
|
||||
# this commit. scripts/ci/ci-ok-verdict.mjs counts the newest attempt of
|
||||
# each, refuses while one is still going, and requires the one that
|
||||
# finished last to be green, so a later green run supersedes a flaky or
|
||||
# cancelled one. It prints the link of every run it counted.
|
||||
- name: Require a green ci-ok on the commit being built
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha="$(git rev-parse HEAD)"
|
||||
runs="$RUNNER_TEMP/ci-ok-runs.json"
|
||||
gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?check_name=ci-ok&filter=all&per_page=100" > "$runs"
|
||||
node scripts/ci/ci-ok-verdict.mjs "$runs" "$sha"
|
||||
|
||||
- name: Determine prerelease version
|
||||
id: bump
|
||||
env:
|
||||
BUMP_INPUT: ${{ inputs.bump || 'auto' }}
|
||||
run: |
|
||||
git fetch --tags
|
||||
|
||||
# Capture the exact commit we're building so build/merge jobs are pinned to it
|
||||
echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
# Get latest stable tag (exclude prerelease tags)
|
||||
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
|
||||
@@ -68,12 +43,14 @@ jobs:
|
||||
STABLE="${STABLE:-0.0.0}"
|
||||
echo "Latest stable: $STABLE"
|
||||
|
||||
IFS='.' read -r MAJOR MINOR _ <<< "$STABLE"
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
|
||||
|
||||
# Detect any in-flight major prerelease (v(MAJOR+1).0.0-pre.*). Stay on that line if found.
|
||||
NEXT_MAJOR="$((MAJOR + 1)).0.0"
|
||||
MAJOR_PRE_EXISTS=$(git tag -l "v${NEXT_MAJOR}-pre.*" | head -1)
|
||||
|
||||
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
|
||||
|
||||
if [ "$BUMP_INPUT" = "major" ] || { [ "$BUMP_INPUT" = "auto" ] && [ -n "$MAJOR_PRE_EXISTS" ]; }; then
|
||||
TARGET="$NEXT_MAJOR"
|
||||
else
|
||||
@@ -86,34 +63,31 @@ jobs:
|
||||
N=$(( ${LAST_N:-0} + 1 ))
|
||||
|
||||
NEW_VERSION="${TARGET}-pre.${N}"
|
||||
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "$STABLE to $NEW_VERSION"
|
||||
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "$STABLE → $NEW_VERSION"
|
||||
|
||||
build:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
needs: version-bump
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Prepare platform tag-safe name
|
||||
env:
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV"
|
||||
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.version-bump.outputs.sha }}
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -126,7 +100,7 @@ jobs:
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
@@ -145,7 +119,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
@@ -153,27 +127,26 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
runs-on: ubuntu-latest
|
||||
needs: [version-bump, build]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.version-bump.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Download build digests
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
# Deliberately NOT merged: the per-platform folder names are what lets
|
||||
# the manifest below be assembled in a fixed order.
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -227,7 +200,7 @@ jobs:
|
||||
run: |
|
||||
KEEP=20
|
||||
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||
BASE_VERSION="${VERSION%%-pre.*}"
|
||||
BASE_VERSION="$(echo "$VERSION" | sed 's/-pre\..*//')"
|
||||
git fetch --tags
|
||||
# Sort by numeric prerelease N (field after -pre.) to get correct ascending order
|
||||
mapfile -t ALL_TAGS < <(git tag -l "v${BASE_VERSION}-pre.*" | awk -F'-pre\\.' '{print $2" "$0}' | sort -n | awk '{print $2}')
|
||||
|
||||
+50
-109
@@ -1,24 +1,10 @@
|
||||
name: Build & Push Docker Image
|
||||
|
||||
# The stable release. Dispatched by hand on main, it:
|
||||
# 1. refuses to run unless ci-ok passed on the main commit it releases,
|
||||
# 2. works out the version from the tags and the bump input, commits the
|
||||
# bump to package.json, the lockfile and Chart.yaml, tags it and pushes,
|
||||
# 3. builds both platforms from exactly that bump commit (never from
|
||||
# whatever main points to by then) and publishes :VERSION,
|
||||
# :MAJOR.MINOR, :MAJOR and :latest,
|
||||
# 4. publishes the Helm chart from the same commit.
|
||||
#
|
||||
# Big or risky changes go out as a prerelease first (docker-dev.yml, tagged
|
||||
# :latest-pre and :<next>-pre.N); bump=auto here then finalizes that line.
|
||||
# Without a prerelease in flight there is no default: the run stops until
|
||||
# someone picks patch, minor or major.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: 'auto finalizes the prerelease in flight; otherwise pick patch, minor or major'
|
||||
description: 'Force bump line (auto = patch/finalize as today)'
|
||||
type: choice
|
||||
options: [auto, patch, minor, major]
|
||||
default: auto
|
||||
@@ -29,7 +15,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
checks: read
|
||||
|
||||
concurrency:
|
||||
group: stable-build
|
||||
@@ -38,65 +23,39 @@ concurrency:
|
||||
jobs:
|
||||
version-bump:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.VERSION }}
|
||||
sha: ${{ steps.bump.outputs.SHA }}
|
||||
steps:
|
||||
- name: Release from main only
|
||||
if: github.ref != 'refs/heads/main'
|
||||
run: |
|
||||
echo "::error::Dispatch this workflow on main, not on $GITHUB_REF."
|
||||
exit 1
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
- 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@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
# The dev push, the main push and any manual run each leave a ci-ok on
|
||||
# this commit. scripts/ci/ci-ok-verdict.mjs counts the newest attempt of
|
||||
# each, refuses while one is still going, and requires the one that
|
||||
# finished last to be green, so a later green run supersedes a flaky or
|
||||
# cancelled one. It prints the link of every run it counted.
|
||||
- name: Require a green ci-ok on the commit being released
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha="$(git rev-parse HEAD)"
|
||||
runs="$RUNNER_TEMP/ci-ok-runs.json"
|
||||
gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?check_name=ci-ok&filter=all&per_page=100" > "$runs"
|
||||
node scripts/ci/ci-ok-verdict.mjs "$runs" "$sha"
|
||||
|
||||
- name: Determine bump type and update version
|
||||
id: bump
|
||||
env:
|
||||
BUMP_INPUT: ${{ inputs.bump || 'auto' }}
|
||||
CONFIRM_MAJOR: ${{ inputs.confirm_major }}
|
||||
run: |
|
||||
set -eu
|
||||
git fetch --tags
|
||||
|
||||
# Derive version from git tags, no package.json dependency
|
||||
# Derive version from git tags — no package.json dependency
|
||||
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
|
||||
STABLE="${STABLE_TAG#v}"
|
||||
STABLE="${STABLE:-0.0.0}"
|
||||
|
||||
PRE_TAG=$(git tag -l 'v*-pre.*' | sort -V | tail -1)
|
||||
|
||||
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
|
||||
|
||||
if [ "$BUMP_INPUT" = "major" ]; then
|
||||
if [ "$CONFIRM_MAJOR" != "MAJOR" ]; then
|
||||
if [ "${{ github.event.inputs.confirm_major }}" != "MAJOR" ]; then
|
||||
echo "::error::confirm_major must equal 'MAJOR' to cut a major release"
|
||||
exit 1
|
||||
fi
|
||||
@@ -109,83 +68,72 @@ jobs:
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||
BUMP="patch"
|
||||
else
|
||||
# auto finalizes the in-flight prerelease and nothing else. It used
|
||||
# to fall back to a patch, which would have shipped any change,
|
||||
# however big, as a patch nobody chose.
|
||||
PRE_BASE=""
|
||||
# auto: finalize in-flight prerelease if one exists, else patch
|
||||
if [ -n "$PRE_TAG" ]; then
|
||||
PRE_BASE="${PRE_TAG#v}"
|
||||
PRE_BASE="${PRE_BASE%-pre.*}"
|
||||
PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)"
|
||||
# Refuse to auto-finalize a major bump — it bypasses confirm_major
|
||||
if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then
|
||||
echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE → $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize."
|
||||
exit 1
|
||||
fi
|
||||
# If prerelease base is strictly greater than stable, finalize it
|
||||
HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1)
|
||||
if [ "$HIGHEST" = "$PRE_BASE" ] && [ "$PRE_BASE" != "$STABLE" ]; then
|
||||
NEW_VERSION="$PRE_BASE"
|
||||
BUMP="finalize"
|
||||
else
|
||||
PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
BUMP="patch"
|
||||
fi
|
||||
else
|
||||
PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
BUMP="patch"
|
||||
fi
|
||||
HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1)
|
||||
if [ -z "$PRE_BASE" ] || [ "$HIGHEST" != "$PRE_BASE" ] || [ "$PRE_BASE" = "$STABLE" ]; then
|
||||
echo "::error::No prerelease above $STABLE is in flight, so bump=auto has nothing to finalize. Dispatch again with bump=patch, minor or major."
|
||||
exit 1
|
||||
fi
|
||||
PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)"
|
||||
# Refuse to auto-finalize a major bump, it would bypass confirm_major
|
||||
if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then
|
||||
echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE to $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize."
|
||||
exit 1
|
||||
fi
|
||||
NEW_VERSION="$PRE_BASE"
|
||||
BUMP="finalize"
|
||||
fi
|
||||
|
||||
# Patch releases have carried migrations before, so this only warns:
|
||||
# whoever pinned :MAJOR.MINOR gets this schema change too.
|
||||
if [ "$BUMP" = "patch" ] && [ -n "$STABLE_TAG" ] \
|
||||
&& [ -n "$(git diff --name-only --diff-filter=A "$STABLE_TAG" HEAD -- server/src/db/migrations/)" ]; then
|
||||
echo "::warning::$NEW_VERSION is a patch release that adds database migrations."
|
||||
fi
|
||||
|
||||
echo "Bump type: $BUMP"
|
||||
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "$STABLE to $NEW_VERSION ($BUMP)"
|
||||
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
|
||||
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. The push fast-forwards main from the commit ci-ok
|
||||
# was checked on, so it fails rather than release past anything that
|
||||
# landed on main in the meantime.
|
||||
# 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 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 push origin HEAD:main --follow-tags
|
||||
|
||||
# build, merge and release-helm check out exactly this commit.
|
||||
echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
git push origin main --follow-tags
|
||||
|
||||
build:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
needs: version-bump
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Prepare platform tag-safe name
|
||||
env:
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV"
|
||||
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.version-bump.outputs.sha }}
|
||||
ref: main
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -198,7 +146,7 @@ jobs:
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
@@ -217,7 +165,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
@@ -225,25 +173,24 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
runs-on: ubuntu-latest
|
||||
needs: [version-bump, build]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.version-bump.outputs.sha }}
|
||||
ref: main
|
||||
|
||||
- name: Download build digests
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
# Deliberately NOT merged: the per-platform folder names are what lets
|
||||
# the manifest below be assembled in a fixed order.
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -254,10 +201,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
|
||||
# :MAJOR.MINOR takes the patches of one minor line and nothing else,
|
||||
# the conservative pin for an install that wants to wait out a
|
||||
# release that migrates.
|
||||
MINOR_TAG="$(echo "$VERSION" | cut -d. -f1-2)"
|
||||
# amd64 first, always. This used to be a glob over hash-named files, so
|
||||
# the order changed from one release to the next: 3.4.1 listed amd64
|
||||
# first and 4.0.0 listed arm64 first. Clients that read the first entry
|
||||
@@ -281,7 +224,6 @@ jobs:
|
||||
--annotation "index:org.opencontainers.image.licenses=AGPL-3.0-only" \
|
||||
-t "mauriceboe/trek:latest" \
|
||||
-t "mauriceboe/trek:$MAJOR_TAG" \
|
||||
-t "mauriceboe/trek:$MINOR_TAG" \
|
||||
-t "mauriceboe/trek:$VERSION" \
|
||||
"${digests[@]}"
|
||||
|
||||
@@ -289,17 +231,16 @@ jobs:
|
||||
run: docker buildx imagetools inspect mauriceboe/trek:latest
|
||||
|
||||
release-helm:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
runs-on: ubuntu-latest
|
||||
needs: version-bump
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.version-bump.outputs.sha }}
|
||||
ref: main
|
||||
|
||||
- name: Publish Helm chart
|
||||
uses: stefanprodan/helm-gh-pages@0ad2bb377311d61ac04ad9eb6f252fb68e207260 # v1.7.0
|
||||
uses: stefanprodan/helm-gh-pages@v1.7.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
charts_dir: charts
|
||||
|
||||
@@ -7,8 +7,7 @@ on:
|
||||
jobs:
|
||||
check-target:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
@@ -16,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Flag or clear wrong base branch
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const base = context.payload.pull_request.base.ref;
|
||||
@@ -69,12 +68,12 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
// Dependabot's security updates cannot be pointed at dev. `target-branch`
|
||||
// in .github/dependabot.yml applies to version updates only; security
|
||||
// updates always open against the default branch. Labelling them meant
|
||||
// close-stale-wrong-branch shut them 24 hours later, which is how 13 of
|
||||
// them died. Retarget instead: a bot cannot click "Edit" the way the
|
||||
// comment below asks a human to.
|
||||
// Dependabot cannot be pointed at dev. `target-branch` in dependabot.yml
|
||||
// applies to version updates only; security updates always open against
|
||||
// the default branch, and security updates are all this repository gets
|
||||
// (there is no dependabot.yml). Labelling them meant close-stale-wrong-branch
|
||||
// shut them 24 hours later, which is how 13 of them died. Retarget instead:
|
||||
// a bot cannot click "Edit" the way the comment below asks a human to.
|
||||
if (context.payload.pull_request.user.login === 'dependabot[bot]') {
|
||||
try {
|
||||
await github.rest.pulls.update({
|
||||
|
||||
@@ -9,34 +9,29 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
node-version: '24'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Run lint & format check
|
||||
# Both are check-only: lint used to run eslint --fix, which rewrote the
|
||||
# files before format:check looked at them, so neither could fail on
|
||||
# formatting. lint:format holds every file outside
|
||||
# scripts/format-baseline.json to Prettier; the list only shrinks.
|
||||
id: checks
|
||||
continue-on-error: true
|
||||
run: |
|
||||
cd shared
|
||||
npm run lint
|
||||
npm run lint:format
|
||||
npm run format:check
|
||||
|
||||
- name: Comment on PR if checks failed
|
||||
if: steps.checks.outcome == 'failure'
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
@@ -50,9 +45,8 @@ jobs:
|
||||
'',
|
||||
'```bash',
|
||||
'cd shared',
|
||||
'npm run lint:fix',
|
||||
'npx prettier --write <file> # each file lint:format names',
|
||||
'npm run lint:format -- --update # when a listed file is formatted now',
|
||||
'npm run lint',
|
||||
'npm run format',
|
||||
'```',
|
||||
'',
|
||||
'Then commit and push the changes.',
|
||||
|
||||
@@ -15,18 +15,17 @@ permissions:
|
||||
jobs:
|
||||
publish:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: plugin-sdk
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
# The Node the TREK image runs the plugins on. It has the node:sqlite
|
||||
# the dev-server tests exercise.
|
||||
node-version-file: .nvmrc
|
||||
# 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
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
name: Security Scan
|
||||
|
||||
# Neither job is a required check. What they report depends on the advisory
|
||||
# databases as much as on the change: a CVE published overnight would turn
|
||||
# every open PR red at once, including the ones that cannot fix it. The
|
||||
# security floors that hold the fixes live in the root package.json's
|
||||
# overrides, which a regenerated lockfile keeps.
|
||||
#
|
||||
# - npm-audit reads the lockfile only, so it runs in seconds on every PR
|
||||
# and push into dev and main. It audits what the image installs (the
|
||||
# server and shared workspaces without dev dependencies) and the
|
||||
# standalone plugin-sdk.
|
||||
# - scout scans the built image, base image packages included. It needs
|
||||
# the Docker Hub login, so it runs on PRs and pushes into main only and
|
||||
# skips fork PRs. Whether the image builds at all is test.yml's
|
||||
# docker-image job, which a red scan cannot hide.
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main, dev]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
@@ -29,57 +14,29 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
npm-audit:
|
||||
name: npm audit
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
# The client's dependencies end up in a browser bundle, not in the
|
||||
# image, and are audited by hand until maplibre-gl's fix (a major) lands.
|
||||
- name: Audit the image's runtime dependencies
|
||||
run: npm audit --package-lock-only --omit=dev --audit-level=high --workspace=server --workspace=shared
|
||||
|
||||
- name: Audit trek-plugin-sdk
|
||||
working-directory: plugin-sdk
|
||||
run: npm audit --package-lock-only --omit=dev --audit-level=high
|
||||
|
||||
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
|
||||
&& (github.base_ref == 'main' || github.ref == 'refs/heads/main')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
if: github.repository == 'liketrek/TREK' && github.event.pull_request.head.repo.fork != true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: trek:scan
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- uses: docker/scout-action@221e7f4860634eeb1579e3bd7ca232e577bd1864 # v1.26.0
|
||||
- uses: docker/scout-action@v1
|
||||
with:
|
||||
command: cves
|
||||
image: trek:scan
|
||||
|
||||
+95
-697
File diff suppressed because it is too large
Load Diff
@@ -18,11 +18,10 @@ concurrency:
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: Publish to GitHub wiki
|
||||
uses: Andrew-Chen-Wang/github-wiki-action@1bbb4280446f9630e8e21a18012cbacf3b0f992e # v5.0.6
|
||||
uses: Andrew-Chen-Wang/github-wiki-action@v5
|
||||
with:
|
||||
strategy: clone
|
||||
|
||||
@@ -4,7 +4,6 @@ node_modules/
|
||||
# Build output
|
||||
client/dist/
|
||||
server/dist/
|
||||
server/VERSION
|
||||
shared/dist/
|
||||
server/public/*
|
||||
!server/public/.gitkeep
|
||||
|
||||
@@ -20,9 +20,8 @@ Run from the repo root unless noted. **Use `npm run ... --workspace=<pkg>`, not
|
||||
```bash
|
||||
npm run dev # build shared, then watch shared + server + client concurrently
|
||||
npm run build # build shared → server → client (order matters)
|
||||
npm run lint # lint all three workspaces, check-only (npm run lint:fix applies the fixes in shared and server)
|
||||
npm run lint # lint all three workspaces (server and shared run eslint --fix and rewrite files; client is check-only)
|
||||
npm run format # prettier --write all three
|
||||
npm run lint:knip # knip over client/server/shared (knip.jsonc): unused files, exports, types, deps may only shrink against scripts/ci/knip-baseline.json (CI gate; --update lowers it)
|
||||
```
|
||||
|
||||
Tests:
|
||||
@@ -33,34 +32,9 @@ npm run test --workspace=server # vitest run (server); also t
|
||||
npm run test:e2e # server e2e (boots Nest against a temp SQLite)
|
||||
npm run test:cov # coverage (lcov) for all four packages incl. plugin-sdk
|
||||
cd client && npm run test # client vitest run
|
||||
cd client && npm run e2e # Playwright (CI runs the public and app projects; the screenshot and help-media ones are local)
|
||||
cd client && npm run e2e # Playwright (local only)
|
||||
cd client && npm run lint:pages # enforce the Page pattern (CI gate)
|
||||
cd client && npm run lint:rtl # physical left/right sides may only shrink per file (CI gate)
|
||||
cd client && npm run lint:dup # lines in copied code may only shrink per file, at SonarCloud's thresholds (CI gate)
|
||||
cd client && npm run lint:pairs # desktop and phone views of a feature import its shared hook; every phone screen is listed (CI gate)
|
||||
cd client && npm run lint:size # no source file past 1000 lines, longer ones only shrink (CI gate; server has the same)
|
||||
cd client && npm run lint:strict # strictNullChecks + noImplicitAny errors may only shrink per file; new files start at zero (CI gate)
|
||||
cd client && npm run lint:warnings # eslint (typed) errors fail, warnings per rule may only shrink, new rules start frozen (CI gate)
|
||||
cd server && npm run lint:warnings # the same for the server, against server/scripts/eslint-baseline.json (CI gate)
|
||||
cd server && npm run lint:format # Prettier outside the baseline list (CI gate; shared has the same in lint-prettier.yml)
|
||||
cd server && npm run lint:strict # strict type errors per file may only shrink, a new file has none (CI gate)
|
||||
cd client && npm run lint:format # Prettier outside the baseline list (CI gate)
|
||||
cd client && npm run lint:layers # components never import pages/ or mobile/ (CI gate)
|
||||
cd client && npm run lint:offline # views may not import src/api/ beyond the baseline (CI gate)
|
||||
cd client && npm run lint:skips # no .only; skipped tests may only shrink (CI gate)
|
||||
cd client && npm run lint:i18n-keys # every key the client names exists in en; unused en keys may only shrink (CI gate)
|
||||
cd server && npm run lint:boundaries # no new import cycles or cross-domain reach-ins (CI gate)
|
||||
cd server && npm run lint:dialect # SQLite-only SQL spellings may only shrink (CI gate; rules in server/CLAUDE.md)
|
||||
cd server && npm run probe:pg # dialect helpers + the repository statements it reaches against Postgres (CI job; needs TREK_PG_PROBE_URL)
|
||||
cd server && npm run lint:tx # no new method writing twice outside one transaction (CI gate)
|
||||
cd server && npm run lint:test-sql # raw SQL fixtures in tests/ may only shrink; seed through tests/helpers/factories (CI gate)
|
||||
cd server && npm run lint:test-mocks # vi.mock of config, realtime and db modules in tests/ may only shrink (CI gate)
|
||||
cd server && npm run lint:test-new-service # hand-built services and repositories in tests/ may only shrink (CI gate)
|
||||
cd server && npm run lint:query-api # MikroORM QueryBuilder calls in the repositories may only shrink (CI gate)
|
||||
cd server && npm run lint:mcp-zod # inline zod in the MCP tool files may only shrink; fields derive from @trek/shared (CI gate)
|
||||
cd server && npm run lint:service-http # { error, status } results and their caller-side branches may only shrink; services throw DomainError (CI gate)
|
||||
cd server && npm run lint:response-contracts # route handlers without @ResponseContract may only shrink (CI gate)
|
||||
cd client && npm run theme:lint # styling that bypasses appearance tokens may only shrink per file (CI gate)
|
||||
cd client && npm run theme:lint # flag styling that bypasses appearance tokens (local only)
|
||||
npm run typecheck --workspace=server # tsc --noEmit (also client/shared; server also has typecheck:tests)
|
||||
```
|
||||
|
||||
@@ -78,7 +52,7 @@ npm run i18n:parity --workspace=shared # audit (exit 0)
|
||||
npm run i18n:parity:strict --workspace=shared # CI gate (exit 1 on drift)
|
||||
```
|
||||
|
||||
Dev ports: the server listens on **3001**; the Vite dev server proxies the API, websocket, uploads, MCP and OAuth paths to it (see `client/vite.config.js`). `.nvmrc` pins the Node major the Docker image runs and every CI job reads; `engines` in each package.json states the range that installs (npm only warns outside it). `server/.env.example` is the env-var reference.
|
||||
Dev ports: the server listens on **3001**; the Vite dev server proxies the API, websocket, uploads, MCP and OAuth paths to it (see `client/vite.config.js`). There is no Node version pin — the CI workflows and the Dockerfile define what is tested. `server/.env.example` is the env-var reference.
|
||||
|
||||
## Server invariants
|
||||
|
||||
@@ -88,8 +62,8 @@ Nest owns everything: every domain is a DI module under `server/src/nest/<domain
|
||||
- **`/mcp` bodies are raw by design** — the parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
|
||||
- **Auth is default-deny**: global guards run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses any public route not on its allow-list.
|
||||
- **Trip-scoped routes** verify trip access (404) and the permission (403) — `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler — and forward `X-Socket-Id` to the broadcast so the originating client doesn't echo its own change. **Never gate a multipart upload with a guard** (the client sees ECONNRESET instead of 403 — check in the handler).
|
||||
- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. MikroORM's migrations (`db/migrations/`) are the only schema source, **including for tests** — the hand-kept `schema.ts`/`migrations.ts`/`seeds.ts` test builders are gone. Seeding is MikroORM's too (`db/seeders/`), applied at boot from `buildApp()`; the ORM shares the one better-sqlite3 connection (bound in `server/src/db/orm-driver.ts`) rather than opening its own. Queries go through repositories (`server/src/db/repositories/`) and `UnitOfWork` (`server/src/nest/database/unit-of-work.ts`) for transactions — there is no `DatabaseService`. `better-sqlite3` itself is ESLint-restricted to `src/db/` plus a short, named allow-list (`server/eslint.config.mjs`), so a domain reaching for the driver directly fails lint. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB.
|
||||
- **Realtime**: `/ws` is a Nest gateway in `server/src/nest/realtime/` — inject `RealtimeService`. Tests observe broadcasts through a `FakeRealtimeService` (`server/tests/helpers/fake-realtime.ts`), never by mocking the transport module.
|
||||
- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. MikroORM's migrations (`db/migrations/`) are the only schema source, **including for tests** — the hand-kept `schema.ts`/`migrations.ts`/`seeds.ts` test builders are gone. Seeding is MikroORM's too (`db/seeders/`), applied at boot from `buildApp()`; the ORM shares the one better-sqlite3 connection (bound in `db/orm-driver.ts`) rather than opening its own. Queries go through repositories (`server/src/db/repositories/`) and `UnitOfWork` (`server/src/nest/database/unit-of-work.ts`) for transactions — there is no `DatabaseService`. `better-sqlite3` itself is ESLint-restricted to `src/db/` plus a short, named allow-list (`server/eslint.config.mjs`), so a domain reaching for the driver directly fails lint. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB.
|
||||
- **Realtime**: `/ws` is a Nest gateway in `server/src/nest/realtime/` — inject `RealtimeService`; `server/src/websocket.ts` is a legacy re-export stub.
|
||||
- **MCP**: tools are declared in `<domain>.mcp.ts` files beside each module, on the `server/src/nest-mcp/` decorator layer. A validation or permission change on a REST route must land in the parallel MCP tool in the same change. See `MCP.md`.
|
||||
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts`.
|
||||
- **Plugins** (`server/src/nest/plugins/`): one forked child process per plugin, permission-gated RPC (a handler is registered only if the plugin holds the unlocking permission), install-time manifest/signature/egress/version-range gates. The protocol tables are **generated** into `plugin-sdk/` and `shared/` by `gen:plugin-facts`; `check:plugin-facts` is the CI gate. Author tooling is `plugin-sdk/`; the `trek-plugin-dev` skill covers authoring.
|
||||
@@ -101,16 +75,14 @@ Nest owns everything: every domain is a DI module under `server/src/nest/<domain
|
||||
Offline-first, with a layered data flow. A Page never owns state directly:
|
||||
|
||||
- **Page pattern** (enforced by `lint:pages`, spec in `client/src/pages/PATTERN.md`): a `*Page.tsx` is a **wiring container** composing a co-located `use<Page>()` hook; it must not call React state/effect/memo hooks itself.
|
||||
- **Data flow**: `store (Zustand, client/src/store/) → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Writes go through `client/src/sync/mutationQueue.ts`: optimistic Dexie write, then a replay with an `X-Idempotency-Key` header on reconnect. `client/src/store/slices/remoteEventHandler.ts` applies incoming WebSocket events. Components never call the API directly.
|
||||
- **Styling**: semantic Tailwind tokens (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`) or the underlying `var(--token)` variables — never color literals, arbitrary-value color classes or numeric inline `fontSize`, so user-chosen scheme/transparency/text-size keep working. `theme:lint` holds the bypasses per file to a baseline that only shrinks (CI gate); mark intentional exceptions (map/PDF/brand) with a `theme-lint-disable` line comment, which is counted too.
|
||||
- **Reading direction**: Arabic runs the app right to left, so spacing, insets and alignment that follow the text use the logical forms (`ms-`/`me-`/`ps-`/`pe-`/`start-`/`end-`/`text-start`/`border-s`/`rounded-s`, `marginInlineStart`, `insetInlineEnd`, `textAlign: 'start'`). `lint:rtl` counts the physical ones per file against `client/scripts/rtl-baseline.json`, which may only shrink; geometry that is physical on purpose in new code (maps, measured positions, time axes) carries an `rtl-lint-disable` comment.
|
||||
- **Data flow**: `store (Zustand, client/src/store/) → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Writes go through `sync/mutationQueue.ts`: optimistic Dexie write, then a replay with an `X-Idempotency-Key` header on reconnect. `store/slices/remoteEventHandler.ts` applies incoming WebSocket events. Components never call the API directly.
|
||||
- **Styling**: semantic Tailwind tokens (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`) or the underlying `var(--token)` variables — never color literals, arbitrary-value color classes or numeric inline `fontSize`, so user-chosen scheme/transparency/text-size keep working. `theme:lint` flags bypasses; suppress intentional exceptions (map/PDF/brand) with a `theme-lint-disable` line comment.
|
||||
- **PWA**: `vite-plugin-pwa` + Workbox cache tiles, API and uploads; `prebuild` generates icons.
|
||||
|
||||
## Shared contracts & i18n
|
||||
|
||||
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema, rebuild shared, then server (validation + DTO types) and client (typed requests) pick it up.
|
||||
- Locale files live in `shared/src/i18n/<locale>/`, one file per domain; `en/` is canonical. When you add or change a key, **add a real translation to every locale** — parity fails CI on missing keys, and an English placeholder is not acceptable in a non-`en` locale.
|
||||
- Count-bearing strings are plural groups: the key holds the general form and `key.one`, `.few` … the forms the language's `Intl.PluralRules` selects. Pass `{ count }` as a number and never choose between two keys with `count === 1`; details in `shared/CLAUDE.md`.
|
||||
|
||||
## Project direction
|
||||
|
||||
@@ -118,7 +90,7 @@ These principles come out of a full-repo audit and shape all new code:
|
||||
|
||||
- **Protect the crown jewels.** The client's offline-first data core (repos + mutation queue + Dexie), the out-of-process plugin sandbox, the Zod contract layer, and the auth primitives are production-grade. Extend them; never route around them or rewrite them in anger.
|
||||
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (on the client: feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
|
||||
- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip — the server entities are the precedent: `npm run gen:entities`/`check:entities --workspace=server` (`server/scripts/generate-entities.ts`) regenerate them from the live migrated schema instead of hand-editing, and a parity test (PARITY-009) pins entity-vs-DB drift (delete rules, index names) at zero skips. The Kysely table types the repositories query through are generated the same way (`gen:db-types`/`check:db-types`, `server/scripts/generate-db-types.ts`).
|
||||
- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip — the server entities are the precedent: `npm run gen:entities`/`check:entities --workspace=server` (`server/scripts/generate-entities.ts`) regenerate them from the live migrated schema instead of hand-editing, and a parity test (PARITY-009) pins entity-vs-DB drift (delete rules, index names) at zero skips.
|
||||
- **DI over global mutable module state.** New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state.
|
||||
- **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents.
|
||||
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not degrade. Never lower a quality gate to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`, no lowered coverage thresholds.
|
||||
@@ -134,14 +106,7 @@ These principles come out of a full-repo audit and shape all new code:
|
||||
|
||||
## Quality gates in CI
|
||||
|
||||
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`, client also the strict-mode ratchet `lint:strict`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips`, `lint:i18n-keys` and `theme:lint`, shared's `contracts:open`, the repo-wide `lint:knip` (in server-quality), `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
|
||||
|
||||
- **`ci-ok` is the required check.** The branch ruleset requires `ci-ok` (plus `lint` and `check-target` from their own workflows), and `ci-ok` needs every job above except the scan. It fails if any of them failed or was cancelled and passes if they passed or were skipped. A new gate job counts only once it is listed in `ci-ok`'s `needs:`.
|
||||
- **Guides name paths that exist**: the `changes` job runs `scripts/ci/doc-paths.mjs`, which resolves every backtick path in the CLAUDE.md files, `README.md`, `server/src/nest/README.md` and the wiki against the tracked files; the missing ones are listed in `scripts/ci/doc-paths-baseline.json`, which only shrinks (`--update` after a fix).
|
||||
- **What runs is decided by `changes`, not by `on.paths`**: `scripts/ci/changed-areas.mjs` maps the changed files to the `code`, `image` and `deploy` areas (tests: `npm run test:ci-scripts`). A path filter on the workflow would leave `ci-ok` unreported on a docs-only PR and block it. Add a new top-level file the build reads to the right area there.
|
||||
- **Runners and Node**: jobs run on `ubuntu-24.04`, not `ubuntu-latest`, and every `setup-node` reads `.nvmrc`. Third-party actions (anything outside `actions/*`) are pinned by commit SHA with the tag in a comment; Dependabot keeps both current.
|
||||
- **Security scans are advisory.** `security.yml` runs `npm audit` (what the image installs, plus `plugin-sdk`) on every PR and push into dev and main, and Docker Scout on the built image for main. Neither is required: a newly published advisory would turn every open PR red at once. Fixes are held as floors in the root `package.json` `overrides`; npm ignores an `overrides` block in a workspace `package.json`, so never put one there.
|
||||
- **Releases** are dispatched by hand and refuse a commit whose `ci-ok` is not green. `docker-dev.yml` (any branch, usually dev) is the canary: it builds the dispatched commit as the next minor or major `-pre.N` and moves only `:latest-pre` and `:<major>-pre`. `docker.yml` (main only) bumps the version, commits and tags it, builds both platforms from exactly that commit and moves `:latest`, `:<major>`, `:<major>.<minor>` and `:<version>`. Its `bump=auto` only finalizes a prerelease in flight; without one it stops and asks for patch, minor or major. Ship a release that migrates or changes a lot as a prerelease first, and as a minor, so installs pinned to `:<major>.<minor>` can wait it out.
|
||||
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), `lint:check`, `lint:pages`, `check:plugin-facts`, `i18n:parity:strict`, the S3 contract suite, coverage for all four packages, then a SonarCloud scan. `lint-prettier.yml` additionally runs `lint` + `format:check` on `shared/`. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint`, Playwright, `check:gl-split` — run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
|
||||
|
||||
**SonarCloud** (project `liketrek_TREK`, the built-in Sonar way gate; the run takes 20–25 min, so get it right before pushing) measures **new code only** on a PR; any failing condition blocks it:
|
||||
|
||||
@@ -152,7 +117,7 @@ These principles come out of a full-repo audit and shape all new code:
|
||||
| Security / reliability / maintainability rating | A |
|
||||
| Security hotspots reviewed | 100% |
|
||||
|
||||
- **Duplication is the one that bites.** Desktop and phone shells (`client/src/components/<X>` ↔ `client/src/mobile/screens/.../M<X>`) are deliberate mirrors of each other's markup; their logic lives in one shared hook per feature. Every line added identically to both counts against the 3% budget, so a feature touching both shells fails on its own. Put the logic in ONE hook/module (precedents: `useInstanceSettings`, `useRangeBypass` under `client/src/components/Admin/`) and leave only markup in each shell. `lint:dup` finds the same copies in `client/src` before the push and holds every file to `client/scripts/dup-baseline.json`; `lint:pairs` holds each pair in `client/scripts/feature-pairs.json` to its shared hook and makes every new phone screen either join a pair or say why it has no twin. Locale files are excluded from duplication, so i18n additions are free.
|
||||
- **Duplication is the one that bites.** Desktop and phone shells (`client/src/components/<X>` ↔ `client/src/mobile/screens/.../M<X>`) are deliberate mirrors that are already heavily duplicated. Every line added identically to both counts against the 3% budget, so a feature touching both shells fails on its own. Put the logic in ONE hook/module (precedents: `useInstanceSettings`, `useRangeBypass` under `client/src/components/Admin/`) and leave only markup in each shell. Locale files are excluded from duplication, so i18n additions are free.
|
||||
- **Coverage is per new line across all four packages.** A new file without tests drags the PR under 80%. Files in `sonar.coverage.exclusions` don't count either way.
|
||||
- **Ratings are about new issues.** One new bug drops reliability; one new vulnerability drops security. Don't introduce hotspot patterns (`Math.random` in anything security-adjacent, `child_process` through PATH, backtracking regexes, secret-looking strings). Path-wide suppressions live only in `sonar-project.properties` with a written justification; one-off findings are resolved on SonarCloud, never with an inline `// NOSONAR`.
|
||||
- **Check it yourself.** The `sonarqube` MCP is connected: `list_pull_requests` → `get_project_quality_gate_status` shows each condition's value; `search_duplicated_files` + `get_duplications` show what tripped duplication; `search_sonar_issues_in_projects` lists new issues. Test files are excluded from analysis — when duplication fires, the source is the problem.
|
||||
|
||||
+6
-18
@@ -2,10 +2,8 @@
|
||||
# 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.26-alpine AS gosu-build
|
||||
# Pinned to the commit of gosu's 1.19 tag: @latest made every build pick up whatever
|
||||
# was pushed last, and the tag itself is not a semver version go install accepts.
|
||||
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@6456aaa0f3c854d199d0f037f068eb97515b7513
|
||||
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
|
||||
@@ -26,9 +24,6 @@ COPY client/scripts/patch-maplibre.mjs ./client/scripts/
|
||||
RUN npm ci --workspace=client
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY client/ ./client/
|
||||
# A prerelease build does not bump client/package.json, so the bundle takes its
|
||||
# version from the same build argument the server's VERSION file comes from.
|
||||
ARG APP_VERSION=dev
|
||||
RUN npm run build --workspace=client
|
||||
|
||||
# ── Stage 3: server ──────────────────────────────────────────────────────────
|
||||
@@ -42,9 +37,7 @@ COPY server/package.json ./server/
|
||||
RUN npm ci --workspace=server --ignore-scripts
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY server/ ./server/
|
||||
# tsc needs more than the 2 GB heap Node picks on a host with about 12 GB of RAM;
|
||||
# on a bigger build machine the default is larger, so only small hosts failed.
|
||||
RUN NODE_OPTIONS=--max-old-space-size=4096 npm run build --workspace=server
|
||||
RUN npm run build --workspace=server
|
||||
|
||||
# ── Stage 4: production runtime ──────────────────────────────────────────────
|
||||
FROM node:24-trixie-slim
|
||||
@@ -75,7 +68,7 @@ RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \
|
||||
libkitinerary-bin libsqlite3-0 && \
|
||||
npm ci --workspace=server --omit=dev --ignore-scripts && \
|
||||
{ ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor || true; } && \
|
||||
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
|
||||
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \
|
||||
chown -R node:node /app
|
||||
|
||||
@@ -127,13 +120,8 @@ RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_USE_ENV_PROXY=1
|
||||
ENV PORT=3000
|
||||
# The version goes into a file, not into ENV. A container keeps its environment
|
||||
# when it is recreated on a newer image (Portainer's recreate, a copied run
|
||||
# config), so an APP_VERSION env var would keep announcing the old release and
|
||||
# the clients would never pick up the new bundle. The server reads this file
|
||||
# first (server/src/app-config/image-version.ts).
|
||||
ARG APP_VERSION=dev
|
||||
RUN if [ "$APP_VERSION" != "dev" ]; then printf '%s\n' "$APP_VERSION" > /app/server/VERSION; fi
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
|
||||
# OCI metadata: Renovate, Watchtower and the registries read the source label to
|
||||
# link an image update to its release notes (#1498). The release workflows add
|
||||
@@ -149,7 +137,7 @@ LABEL org.opencontainers.image.title="TREK" \
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- "http://localhost:${PORT:-3000}/api/health" || exit 1
|
||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
# Start-up lives in a script, not an inline `sh -c` string: container management
|
||||
# UIs re-tokenise Config.Cmd when you edit a container in place, and a command
|
||||
|
||||
@@ -43,7 +43,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK2.webp" alt="TREK, a tour of the app" width="100%" />
|
||||
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.webp" alt="TREK, a tour of the app" width="100%" />
|
||||
|
||||
</div>
|
||||
|
||||
@@ -223,7 +223,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
@@ -16,12 +16,6 @@ data:
|
||||
{{- if .Values.env.TREK_WIKI_DIR }}
|
||||
TREK_WIKI_DIR: {{ .Values.env.TREK_WIKI_DIR | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.DEFAULT_LANGUAGE }}
|
||||
DEFAULT_LANGUAGE: {{ .Values.env.DEFAULT_LANGUAGE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.MCP_MAX_SESSION_PER_USER }}
|
||||
MCP_MAX_SESSION_PER_USER: {{ .Values.env.MCP_MAX_SESSION_PER_USER | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.ALLOWED_ORIGINS }}
|
||||
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -9,7 +9,7 @@ spec:
|
||||
# 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. Keep it, see updateStrategy in values.yaml.
|
||||
# the old pod down first. Override to RollingUpdate only with a ReadWriteMany volume.
|
||||
strategy:
|
||||
type: {{ .Values.updateStrategy | default "Recreate" }}
|
||||
selector:
|
||||
@@ -40,14 +40,10 @@ spec:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.env.PORT | default 3000 }}
|
||||
- containerPort: 3000
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "trek.fullname" . }}-config
|
||||
{{- with .Values.extraEnvFrom }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
- name: ENCRYPTION_KEY
|
||||
valueFrom:
|
||||
@@ -103,42 +99,11 @@ spec:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: VAPID_PRIVATE_KEY
|
||||
optional: true
|
||||
- name: SMTP_PASS
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: SMTP_PASS
|
||||
optional: true
|
||||
- name: MAPBOX_ACCESS_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: MAPBOX_ACCESS_TOKEN
|
||||
optional: true
|
||||
- name: CARTO_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: CARTO_API_KEY
|
||||
optional: true
|
||||
- name: DEMO_ADMIN_PASS
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: DEMO_ADMIN_PASS
|
||||
optional: true
|
||||
{{- with .Values.extraEnv }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
{{- with (default dict .Values.probes).startup }}
|
||||
startupProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with (default dict .Values.probes).liveness }}
|
||||
livenessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
|
||||
@@ -32,18 +32,6 @@ data:
|
||||
{{- if .Values.secretEnv.VAPID_PRIVATE_KEY }}
|
||||
VAPID_PRIVATE_KEY: {{ .Values.secretEnv.VAPID_PRIVATE_KEY | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.SMTP_PASS }}
|
||||
SMTP_PASS: {{ .Values.secretEnv.SMTP_PASS | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.MAPBOX_ACCESS_TOKEN }}
|
||||
MAPBOX_ACCESS_TOKEN: {{ .Values.secretEnv.MAPBOX_ACCESS_TOKEN | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.CARTO_API_KEY }}
|
||||
CARTO_API_KEY: {{ .Values.secretEnv.CARTO_API_KEY | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.DEMO_ADMIN_PASS }}
|
||||
DEMO_ADMIN_PASS: {{ .Values.secretEnv.DEMO_ADMIN_PASS | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
|
||||
@@ -86,16 +74,4 @@ stringData:
|
||||
{{- if .Values.secretEnv.VAPID_PRIVATE_KEY }}
|
||||
VAPID_PRIVATE_KEY: {{ .Values.secretEnv.VAPID_PRIVATE_KEY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.SMTP_PASS }}
|
||||
SMTP_PASS: {{ .Values.secretEnv.SMTP_PASS | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.MAPBOX_ACCESS_TOKEN }}
|
||||
MAPBOX_ACCESS_TOKEN: {{ .Values.secretEnv.MAPBOX_ACCESS_TOKEN | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.CARTO_API_KEY }}
|
||||
CARTO_API_KEY: {{ .Values.secretEnv.CARTO_API_KEY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.DEMO_ADMIN_PASS }}
|
||||
DEMO_ADMIN_PASS: {{ .Values.secretEnv.DEMO_ADMIN_PASS | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -8,7 +8,7 @@ spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
targetPort: 3000
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
|
||||
+12
-37
@@ -4,10 +4,9 @@ image:
|
||||
# tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# Deployment update strategy. Keep Recreate: TREK is a single-writer SQLite app, and
|
||||
# during a RollingUpdate the new pod migrates the database while the old one still
|
||||
# writes to it. A ReadWriteMany volume does not make that safe; SQLite's WAL needs both
|
||||
# processes on one host, which network filesystems do not provide.
|
||||
# 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
|
||||
@@ -34,7 +33,7 @@ env:
|
||||
# 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.
|
||||
# Supported: ar, az, br, ca, cs, de, en, es, et, fr, gr, hu, id, it, ja, ko, nl, pl, ru, sk, sv, th, tr, uk, vi, zh, zh-TW
|
||||
# Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||
# ALLOWED_ORIGINS: ""
|
||||
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
|
||||
# APP_URL: "https://trek.example.com"
|
||||
@@ -169,19 +168,15 @@ secretEnv:
|
||||
# Leave both empty to let TREK keep a generated pair in its database.
|
||||
VAPID_PRIVATE_KEY: ""
|
||||
|
||||
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades.
|
||||
# Only with helm install/upgrade against the cluster: under `helm template`, ArgoCD or Flux
|
||||
# the chart cannot read the existing secret and renders a new key every time. TREK then
|
||||
# refuses to start rather than lose its secrets; use existingSecret in those setups.
|
||||
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
|
||||
generateEncryptionKey: false
|
||||
|
||||
# If set, the chart renders no Secret of its own and instead reads every secret env
|
||||
# var from this existing Kubernetes secret. Expected keys: ENCRYPTION_KEY (rename via
|
||||
# existingSecretKey), ADMIN_EMAIL, ADMIN_PASSWORD, OIDC_CLIENT_SECRET,
|
||||
# UNSPLASH_ACCESS_KEY, PLACES_API_KEY, AMAP_API_KEY, AMAP_API_SECRET,
|
||||
# VAPID_PRIVATE_KEY, SMTP_PASS, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY, DEMO_ADMIN_PASS.
|
||||
# Missing keys are simply left unset (they are optional), so only provide the ones your
|
||||
# setup needs.
|
||||
# VAPID_PRIVATE_KEY. Missing keys are simply left unset (they are optional), so only
|
||||
# provide the ones your setup needs.
|
||||
existingSecret: ""
|
||||
existingSecretKey: ENCRYPTION_KEY
|
||||
|
||||
@@ -207,40 +202,20 @@ persistence:
|
||||
# httpGet handler. Switching handler means clearing the old one in the same override
|
||||
# (httpGet: null together with tcpSocket), otherwise the API rejects the pod with
|
||||
# "may not specify more than 1 handler type". Set probes.liveness to null to drop a probe.
|
||||
#
|
||||
# The startup probe gives a first boot, a long migration or a restore at boot up to ten
|
||||
# minutes before liveness takes over; liveness alone killed such a boot after about
|
||||
# 105 s, and the next start began it again. Readiness asks the database, so a pod whose
|
||||
# database does not answer stops getting traffic without being restarted.
|
||||
probes:
|
||||
startup:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
failureThreshold: 60
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
port: 3000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /api/health/ready
|
||||
port: http
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
|
||||
# Further environment variables, for any setting the env block above does not list.
|
||||
# extraEnv:
|
||||
# - name: SESSION_DURATION
|
||||
# value: 7d
|
||||
extraEnv: []
|
||||
# Whole ConfigMaps or Secrets:
|
||||
# extraEnvFrom:
|
||||
# - secretRef:
|
||||
# name: trek-smtp
|
||||
extraEnvFrom: []
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
|
||||
+12
-32
@@ -8,28 +8,15 @@ Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PW
|
||||
npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001
|
||||
npm run build # prebuild generates PWA icons, then vite build
|
||||
npm run typecheck # tsc --noEmit (CI)
|
||||
npm run lint:strict # tsc over tsconfig.strict.json (strictNullChecks + noImplicitAny): errors per file may only shrink against scripts/strict-baseline.json, a new file starts at zero (CI gate; --list shows them, --update lowers it)
|
||||
npm run lint # eslint . (CI runs lint:warnings instead, which fails on the same errors)
|
||||
npm run lint:warnings # eslint (typed), failing on any error and on any warning count per rule above scripts/eslint-baseline.json and on any entry above its count (app code, tests and eslint-disable'd messages counted apart; a `/* eslint <rule>: ... */` config comment fails outright; --update lowers it). Frozen as warnings: floating/misused promises, `.catch(() => {})` (trek/no-swallowed-catch), react-hooks v7 recommended, complexity/max-depth/max-params/max-lines-per-function (120 per .ts function, 300 per .tsx), `use*Store()` without a selector, `window.__*` and window.dispatchEvent buses (trek/no-window-globals), no-console, no-non-null-assertion; the local rules live in scripts/lib/eslint-rules.mjs
|
||||
npm run lint # eslint . (lint:check in CI)
|
||||
npm run lint:pages # enforce the Page pattern (CI gate)
|
||||
npm run lint:rtl # physical left/right sides and rtl-lint-disable comments may only shrink per file (CI gate; --list shows them, --update lowers both baselines)
|
||||
npm run lint:size # no source file or stylesheet past 1000 lines, no test (Playwright specs and fixtures under e2e/ included) past 2000, counted wrapped at 120 columns; longer ones may only shrink (CI gate; --update lowers the baseline)
|
||||
npm run lint:format # Prettier: every .ts/.tsx/.mjs/.css file under src/, tests/, e2e/ and scripts/ outside scripts/format-baseline.json must be formatted, the list only shrinks (CI gate; npx prettier --write <file>, then --update)
|
||||
npm run lint:layers # imports go downwards: components never import pages/ or mobile/, nothing under the views imports a view; per-file counts may only shrink (CI gate; --list, --update)
|
||||
npm run lint:offline # no view file (components/mobile/pages/hooks) imports src/api/ beyond scripts/offline-baseline.json, which only shrinks (CI gate; --list, --update)
|
||||
npm run lint:pairs # each desktop/phone pair in scripts/feature-pairs.json imports its shared hooks, and every file under src/mobile/screens is listed there, in a pair or under mobileOnly with a reason (CI gate; --list prints the pairs)
|
||||
npm run lint:dup # copied code: the lines of a file inside a block that repeats elsewhere (100 tokens over 10 lines, SonarCloud's thresholds; .ts and .tsx read as one language) may only shrink against scripts/dup-baseline.json (CI gate; --list shows each block with its other side, --update lowers it)
|
||||
npm run lint:skips # no .only anywhere; skipped/todo/fixme tests per file may only shrink against scripts/skip-baseline.json (CI gate; skipIf/runIf and Playwright's test.skip(condition, 'why') are fine, but skipIf(true) and runIf(false) count as skips)
|
||||
npm run lint:i18n-keys # every translation key src/ names exists in shared en, and en keys nothing in src/, server/src or plugin-sdk/src reaches may only shrink per en file against scripts/i18n-unused-baseline.json (CI gate; --unused lists them, --update lowers the baseline)
|
||||
npm run test # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage
|
||||
npm run e2e # Playwright (CI runs --project=public --project=app; e2e:report opens the last report)
|
||||
npm run e2e # Playwright (local only; e2e:report opens the last report)
|
||||
npm run shots # Playwright screenshot project (shots:promote to accept)
|
||||
npm run theme:lint # colour literals, palette classes, raw text sizes, z-index literals above z-50 and dark_mode reads may only shrink per file, theme-lint-disable markers too (CI gate; --list shows them, --update lowers both baselines)
|
||||
npm run check:gl-split # after build: fails if one chunk bundles both mapbox-gl and maplibre-gl (CI gate)
|
||||
npm run theme:lint # theme conformance audit (theme:lint:strict exits 1; local only)
|
||||
npm run check:gl-split # after build: fails if one chunk bundles both mapbox-gl and maplibre-gl (local only)
|
||||
```
|
||||
|
||||
Every baseline ratchet above also fails on an entry that allows more than the tree holds: a count above what its file or rule has now, or a file that is gone. Run the check with `--update` in the change that made it smaller, so a fixed file cannot grow back to its old entry and a new file at a deleted path does not inherit one.
|
||||
|
||||
Single test: `npx vitest run src/store/slices/budgetSlice.test.ts`, or `npx vitest run -t "optimistically adds the place"`.
|
||||
|
||||
The dev server needs the API on **:3001** — run `npm run dev` at the repo root to start both.
|
||||
@@ -45,16 +32,15 @@ The layering is **component → feature hook → store/slice → `repo/` → `ap
|
||||
- **`src/store/`** — Zustand. `tripStore.ts` is composed from slices in `store/slices/`; `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state.
|
||||
- **`src/repo/`** — per-entity repositories mediating between the network and the offline cache: online, call REST and upsert into Dexie; offline, read Dexie. Always go through a repo for trip data.
|
||||
- **`src/api/client.ts`** — the single Axios instance, typed from `@trek/shared`. New API surface goes in a per-domain `api/<domain>.ts`, not the god module.
|
||||
- **`src/db/offlineDb.ts`** — the Dexie schema. Database and table names are the on-device contract — renaming one orphans every user's cache. Versions are append-only: a new `this.version(N)` adds its stores to `HISTORY` and a case to `UPGRADE_CASES` in `tests/unit/db/offlineDb.upgrade.test.ts`, which opens the previous version with seeded rows in fake-indexeddb and upgrades it to the current one; a guard there fails on a declared version without both. Never edit a shipped version's stores or `upgrade()`.
|
||||
- **`src/sync/`**: in `mutationQueue.ts`, offline writes do an optimistic Dexie write (temporary **negative id** for creates), then `flush()` replays REST on reconnect with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the row. Writes to one entity keep their order: a write parked as `failed` or `conflict` holds back the later writes to the same `resource` + entity id until the user retries, resolves or discards it, and only that action deletes a parked row (trip eviction never does). Every row `enqueue()` writes carries `schemaVersion` (`MUTATION_SCHEMA_VERSION` in `offlineDb.ts`) and `buildVersion`; bump the constant when a row's fields or the way the replay reads them change incompatibly. A flush replays its own format and older ones (an unstamped row is format 1) and skips a row from a newer format without sending, marking or dropping it, holding back the later writes to its entity, so a tab still on an older bundle never replays a shape it does not know. The guard exists only from the release that introduced `schemaVersion` on; a bundle older than that replays every row, so the first bump of `MUTATION_SCHEMA_VERSION` must not ship while a rollback target older than the guard is still in play. A skipped row stays `pending` with no retry or discard path, so on a rolled-back install it holds its entity until the user clears the cache. Repos whose writes carry no version token (visits, day clears, tours, driving settings) ask `mutationQueue.mustQueue()` before an online write and queue it while an older write to the entity is still queued, so a retried write cannot land over a newer one made here online; places and packing items send `X-Base-Updated-At`, so the server refuses the stale replay with a 409 instead. `flush()` takes the Web Lock `trek-mutation-flush:<db name>` so only one tab replays at a time; a tab that finds it taken waits for it, and a `flush()` called while this tab's flush runs or waits for the lock gets that same flush back (with one more pass asked for), so `flush()` never resolves while a tab is still replaying and a re-seed chained onto it cannot lay older server rows over those writes (the in-tab flush alone where the API is missing). The rest of the directory drives reconnection sync, the auth gate that stops background loops before logout swaps the DB, offline preferences, and map pre-download (raster and vector prefetchers).
|
||||
- **`src/db/offlineDb.ts`** — the Dexie schema. Database and table names are the on-device contract — renaming one orphans every user's cache.
|
||||
- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (temporary **negative id** for creates), then `flush()` replays REST on reconnect with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the row. The rest of the directory drives reconnection sync, the auth gate that stops background loops before logout swaps the DB, offline preferences, and map pre-download (raster and vector prefetchers).
|
||||
|
||||
## Rules for new code
|
||||
|
||||
The offline core is flagship work surrounded by a periphery that ignores it. New code extends the core's discipline, not the periphery's shortcuts:
|
||||
|
||||
- **One data path, no layer skips.** Never import `api/client` (or raw fetch/axios) from a component or modal, and especially never *write* from one. `lint:offline` enforces it: a file under `components/`, `mobile/`, `pages/` or `hooks/` may not import anything from `src/api/` except types. The long tail that already does is listed in `scripts/offline-baseline.json`; the list only shrinks (a file that stops leaves it via `--update`) and is debt, not precedent. A domain that is online-only on purpose (admin, auth) still puts its calls in a store or repo, not in the view.
|
||||
- **One data path, no layer skips.** Never import `api/client` (or raw fetch/axios) from a component or modal — especially never *write* from one. A long tail of existing files bypasses the layering and no lint ratchet holds the line yet; that is debt, not precedent.
|
||||
- **Offline-first is the product promise.** New domains get a repo with read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry). If a domain is deliberately online-only, say so explicitly.
|
||||
- **Imports go downwards** (`lint:layers`, rules in `scripts/lib/layers.mjs`). A component never imports `pages/` or `mobile/`, a shared hook no view, and stores, repos, sync, api, db and the helpers no view at all; a file directly in `src/` is the layer of its name, so `src/types.ts` follows the `types/` rules. A model or helper both sides need moves down (`utils/`, `hooks/`, `types/`); the counts in `scripts/layers-baseline.json` are debt, not precedent.
|
||||
- **No god components or god hooks.** The page pattern is a floor, not a ceiling. Decompose hooks by concern, split renders into memoizable sections, and don't pass huge prop bags with inline-arrow callbacks.
|
||||
- **Prefer React 19 idioms**: `useOptimistic`, `useActionState`/`useFormStatus`, `use()` + Suspense, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference).
|
||||
- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`.
|
||||
@@ -64,25 +50,19 @@ The offline core is flagship work surrounded by a periphery that ignores it. New
|
||||
- **Rendering security**: never interpolate user content into HTML strings (map popups included — build via DOM + `textContent`, or `escapeHtml` from `@trek/shared`); untrusted markdown gets `rehype-sanitize`; never `rehype-raw` near it.
|
||||
- **No `window` event buses or global mutable `window` state.** No `any` at boundaries: WS payloads and map renderer props get real types.
|
||||
- **Hygiene**: error boundaries around new shells/routes (`components/shared/ErrorBoundary.tsx`); every async `.then(setState)` needs a cancelled flag or `AbortController`; no routine `eslint-disable exhaustive-deps` (a missing dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops; search for an existing utility before writing a duplicate.
|
||||
- **Theming**: use the semantic Tailwind tokens defined in `tailwind.config.js` (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`, status colors) — no palette classes, hex literals, arbitrary-value color classes, or invented CSS vars. Only `applyAppearance()` in `src/theme/` mutates `<html>` styling (see `src/theme/README.md`). `theme:lint` counts literals, `bg-[#...]`-style and palette classes, raw `text-sm`- and `text-[13px]`-style sizes, z-index literals and `dark_mode` reads per file against `scripts/theme-baseline.json`.
|
||||
- **Theming**: use the semantic Tailwind tokens defined in `tailwind.config.js` (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`, status colors) — no palette classes, hex literals, arbitrary-value color classes, or invented CSS vars. Only `applyAppearance()` in `src/theme/` mutates `<html>` styling (see `src/theme/README.md`). `theme:lint` catches literals and `bg-[#...]`-style classes but not named palette classes — review those by hand.
|
||||
|
||||
## Big-picture pieces
|
||||
|
||||
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet and Mapbox/MapLibre GL — chosen at runtime by `MapViewAuto.tsx`. Keep both in sync when changing map features, and keep `mapbox-gl` and `maplibre-gl` in separate chunks (`check:gl-split`). The raster prefetcher fetches tiles `no-cors` so custom tile providers without CORS headers keep working; the vector prefetcher deliberately uses `cors` because it has to read the responses — don't "align" one with the other.
|
||||
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled; every other locale is a dynamic `import('@trek/shared/i18n/<locale>')` so Vite emits one chunk per locale. Strings live in `shared/`, never here. `t()` returns an unknown key unchanged, so `lint:i18n-keys` (`scripts/i18n-keys.mjs`) resolves every literal key in `t()`, `tHtml()`, `tr()`, `<TransHtml html>`, `translateApiError` and `*Key` props against `shared/src/i18n/en`; a template whose interpolations only choose between string literals is expanded and each branch checked as a key; any other template key is read with each interpolation as one key segment and passes by itself only when it reaches between 1 and `MAX_IMPLICIT_MATCHES` (30) en keys; a wider one, or one with no fixed dotted prefix, needs a `DYNAMIC_ALLOWED` entry keyed by file and template that says what bounds the value, and an entry the check no longer needs fails. Pass counts as numbers, and let a plural group pick the wording instead of switching keys on the count.
|
||||
- **Mobile shell** (`src/mobile/`): below the phone breakpoint (`useIsPhone`) `App.tsx` wraps routes in `MobileShell` and the `M*` screens under `mobile/screens/` take over. A UI change to a domain with an `M*` twin usually needs both, and the logic change goes into the shared hook (see "Desktop and phone views" below).
|
||||
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled; every other locale is a dynamic `import('@trek/shared/i18n/<locale>')` so Vite emits one chunk per locale. Strings live in `shared/`, never here.
|
||||
- **Mobile shell** (`src/mobile/`): below the phone breakpoint (`useIsPhone`) `App.tsx` wraps routes in `MobileShell` and the `M*` screens under `mobile/screens/` take over. A UI change to a domain with an `M*` twin usually needs both — and the shared-hook rule below.
|
||||
- **Plugins UI** (`src/components/Plugins/`): third-party surfaces render only inside the sandboxed `PluginFrame` iframe (postMessage bridge, live theme-token sync). Host-rendered contribution points (widgets, schedule rows, map layers, planner columns/actions) render server-normalized primitives only — plugin markup never runs inline. Authoring is covered by the `trek-plugin-dev` skill.
|
||||
- **Managed installs** (`src/managed/index.tsx`): the attachment point for screens that only exist on a centrally administered install. Empty here by design — an operator replaces it at build time. Don't put features there.
|
||||
|
||||
## Desktop and phone views
|
||||
## Desktop ↔ mobile shells and the SonarQube duplication gate
|
||||
|
||||
Most features have a desktop view (`components/`, `pages/`) and a phone view (`mobile/screens/`). They share one hook or model per feature that holds the logic (state, effects, loading, handlers, validation, derived values, store and repo calls); each view keeps only its own markup, layout and dialogs or sheets. `useInstanceSettings` and `useRangeBypass` under `components/Admin/` show the shape. `scripts/feature-pairs.json` lists every pair with its hooks.
|
||||
|
||||
- **Changing a feature:** change the shared hook, not one view. A behaviour that really differs between the two views is a parameter of the hook, so both stay visible in one place and its test covers both.
|
||||
- **A new feature with both views:** write the hook first under `components/<Feature>/` (or `hooks/`/`utils/` when it is not bound to one feature; a page's own logic stays in its `use<Page>` hook and models in `pages/<page>/`, which the phone shell imports too), give it its own test, build both views on it and add the pair to `scripts/feature-pairs.json`.
|
||||
- **A new phone screen:** list it in `scripts/feature-pairs.json`, as a view of the pair whose hook it runs on or under `mobileOnly` with the reason it has no twin. A file under `mobile/screens/` that is in neither fails `lint:pairs`.
|
||||
|
||||
Two CI gates hold this: `lint:pairs` fails when a listed view stops importing its pair's hook or a phone screen is not listed, and `lint:dup` holds the lines inside copied blocks to `scripts/dup-baseline.json`, so a copy that looks alike fails too. SonarCloud allows **≤ 3% duplicated lines on the PR's new code**, so the same lines added to both views fail a PR by themselves. Full gate rules in the root `CLAUDE.md`.
|
||||
Every desktop component with a phone twin is a deliberate copy that is already heavily duplicated. CI's SonarCloud gate allows **≤ 3% duplicated lines on the PR's new code**, so adding the same lines to both shells fails the PR by itself. Put logic in one shared hook/module (`useInstanceSettings`, `useRangeBypass` under `components/Admin/` are the pattern) and keep only markup in each shell. Full gate rules in the root `CLAUDE.md`.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -459,12 +459,12 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
target: p => card(p).getByText('Upload', { exact: true }),
|
||||
act: async p => {
|
||||
await card(p).locator('input[type="file"][multiple]').setInputFiles(await coverFixture())
|
||||
await expect(card(p).getByRole('button', { name: '1 file', exact: true })).toBeVisible({ timeout: 30_000 })
|
||||
await expect(card(p).getByRole('button', { name: '1 files' })).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// The list opens itself once the upload lands.
|
||||
only(p => card(p).getByRole('button', { name: '1 file', exact: true })),
|
||||
only(p => card(p).getByRole('button', { name: '1 files' })),
|
||||
// No click: openFile hands the browser a download.
|
||||
only(p => card(p).getByRole('button', { name: UPLOADED })),
|
||||
],
|
||||
|
||||
@@ -650,7 +650,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await importCta(p).click()
|
||||
await expect(p.getByText('Import from AirTrail', { exact: true })).toHaveCount(0, { timeout: 30_000 })
|
||||
// The toast counts flight ids, so the joined pair reports two.
|
||||
const toast = p.getByText('2 flights imported', { exact: true })
|
||||
const toast = p.getByText('2 flight(s) imported', { exact: true })
|
||||
await expect(toast).toBeVisible({ timeout: 15_000 })
|
||||
await expect(card(p, JOINED.title)).toBeVisible({ timeout: 20_000 })
|
||||
// The toast fades after three seconds; the card's picture and the
|
||||
|
||||
@@ -10,13 +10,11 @@ import type { Page } from '@playwright/test'
|
||||
* 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 three ways depending on its shape:
|
||||
* 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.
|
||||
* - The release notes (ReleaseNoticeModal), shown again after every update,
|
||||
* close through their X (`aria-label="Close"`).
|
||||
* 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).
|
||||
@@ -34,10 +32,8 @@ export async function dismissSystemNotices(page: Page, appearTimeoutMs = 3_000):
|
||||
}
|
||||
const dismiss = dialog.getByRole('button', { name: 'Dismiss', exact: true })
|
||||
const ok = dialog.getByRole('button', { name: 'OK', exact: true })
|
||||
const close = dialog.getByRole('button', { name: 'Close', exact: true })
|
||||
if (await dismiss.isVisible().catch(() => false)) await dismiss.click()
|
||||
else if (await ok.isVisible().catch(() => false)) await ok.click()
|
||||
else if (await close.isVisible().catch(() => false)) await close.click()
|
||||
else break
|
||||
// Exit animation + the next queued notice mounting.
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
@@ -5,19 +5,16 @@ import { dismissSystemNotices } from './helpers'
|
||||
//
|
||||
// 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. What
|
||||
// turned the swipe into a drag was the drag-drop-touch polyfill, confined to hybrid
|
||||
// laptops since. #1616 then gave tablets their drag back on purpose, through the
|
||||
// long-press bridge ([data-touch-drag]): a row may be draggable, but only a press that
|
||||
// is held becomes a drag, so a swipe still scrolls. Only a real device context proves
|
||||
// that; a jsdom unit test cannot express "coarse pointer at 834px".
|
||||
// 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: the places list scrolls under a swipe', async ({ page }) => {
|
||||
test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
|
||||
await dismissSystemNotices(page)
|
||||
@@ -61,22 +58,12 @@ test('#1432 iPad: the places list scrolls under a swipe', async ({ page }) => {
|
||||
expect(env.coarse, 'iPad reports a coarse primary pointer').toBe(true)
|
||||
expect(env.width, 'iPad sits above the 768px "mobile" breakpoint').toBeGreaterThanOrEqual(768)
|
||||
|
||||
// 1. A swipe must not become a drag. What turned it into one in #1432 was the
|
||||
// drag-drop-touch polyfill; it belongs to hybrid laptops only now
|
||||
// (utils/touchDragPolyfill.ts), so a tablet must never load it. A row may be
|
||||
// draggable, but only behind the long-press bridge (#1616).
|
||||
const polyfillLoaded = await page.evaluate(() =>
|
||||
performance.getEntriesByType('resource').some(e => /drag-?drop-?touch/i.test(e.name)))
|
||||
expect(polyfillLoaded, 'the drag-drop-touch polyfill stays off a tablet').toBe(false)
|
||||
// 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).toBeVisible()
|
||||
const swipeSafe = await row.evaluate(el => el.getAttribute('draggable') !== 'true' || !!el.closest('[data-touch-drag]'))
|
||||
expect(swipeSafe, 'a draggable row sits behind the long-press bridge').toBe(true)
|
||||
await expect(row).toHaveAttribute('draggable', 'false')
|
||||
|
||||
// 2. The list must scroll, and no drop-to-import overlay may appear.
|
||||
// The list's scroll container carries trek-stagger among its other classes. Taken
|
||||
// from the row above: the first draggable on the page can be another panel's.
|
||||
const scroller = row.locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " trek-stagger ")]')
|
||||
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)
|
||||
@@ -85,11 +72,17 @@ test('#1432 iPad: the places list scrolls under a swipe', async ({ page }) => {
|
||||
expect(after, 'places list scrolled').toBeGreaterThan(before)
|
||||
await expect(page.getByText('Drop to import')).toHaveCount(0)
|
||||
|
||||
// 3. The iPad must still get the desktop shell — isMobile stayed width-based. One
|
||||
// 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 shell — isMobile stayed width-based. One
|
||||
// panel at a time (#2247), so opening Places closed the day plan and left its
|
||||
// tab as the way back.
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible()
|
||||
// The top tab bar has a button named Plan as well; the way back is the panel's
|
||||
// own tab, which is accented while its panel is closed.
|
||||
await expect(page.locator('button[aria-label="Plan"].bg-accent')).toBeVisible()
|
||||
await expect(page.locator('button[aria-label="Plan"]')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -71,29 +71,18 @@ test('#1809 iPhone: flow screens scroll the document, full-screen ones do not',
|
||||
await expect.poll(() => page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(0)
|
||||
|
||||
// 4. The shared body scroll lock: with the document as the scroller, an open
|
||||
// sheet must freeze the page behind it and give the position back. The
|
||||
// lock is body overflow: hidden, which reaches the viewport only while
|
||||
// html stays overflow: visible (index.css), and there it stops the swipe.
|
||||
// It does not stop a script: window.scrollBy moves an overflow: hidden
|
||||
// viewport in every engine, so the lock is read where it takes effect.
|
||||
// sheet must freeze the page behind it and give the position back.
|
||||
await page.evaluate(() => window.scrollBy(0, 300))
|
||||
const locked = await page.evaluate(readScroll)
|
||||
expect(locked.top).toBeGreaterThan(0)
|
||||
|
||||
await page.getByRole('button', { name: 'New Trip' }).click()
|
||||
await expect(page.getByRole('dialog')).toBeVisible()
|
||||
const lock = await page.evaluate(() => ({
|
||||
body: getComputedStyle(document.body).overflowY,
|
||||
html: getComputedStyle(document.documentElement).overflowY,
|
||||
top: document.scrollingElement!.scrollTop,
|
||||
}))
|
||||
expect(lock.body, 'the open sheet locks body').toBe('hidden')
|
||||
expect(lock.html, 'html stays visible, so the lock reaches the viewport').toBe('visible')
|
||||
expect(lock.top, 'opening the sheet does not move the page').toBe(locked.top)
|
||||
await page.evaluate(() => window.scrollBy(0, 300))
|
||||
expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(locked.top)
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0)
|
||||
expect(await page.evaluate(() => getComputedStyle(document.body).overflowY), 'the lock is released').not.toBe('hidden')
|
||||
expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(locked.top)
|
||||
await page.evaluate(() => window.scrollBy(0, 200))
|
||||
expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBeGreaterThan(locked.top)
|
||||
|
||||
@@ -5,7 +5,6 @@ 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';
|
||||
import trek from './scripts/lib/eslint-rules.mjs';
|
||||
|
||||
// Minimal stub so the existing `// eslint-disable-next-line react/no-danger`
|
||||
// directive in src/i18n/TransHtml.tsx resolves without pulling in the full
|
||||
@@ -47,11 +46,6 @@ export default tseslint.config(
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
react: reactStub,
|
||||
trek,
|
||||
},
|
||||
languageOptions: {
|
||||
// Typed linting, for the rules that need to know a value is a promise.
|
||||
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
|
||||
},
|
||||
rules: {
|
||||
'react/no-danger': 'off',
|
||||
@@ -60,12 +54,8 @@ export default tseslint.config(
|
||||
// --- 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)
|
||||
|
||||
// A hook called conditionally is a crash waiting for the render that skips it.
|
||||
// The rest of react-hooks v7's recommended set (the React Compiler's checks:
|
||||
// state set in effects or render, refs read in render, impure renders,
|
||||
// mutated props) as warnings, each count held by scripts/eslint-baseline.json.
|
||||
...Object.fromEntries(Object.keys(reactHooks.configs['recommended-latest'].rules).map((rule) => [rule, 'warn'])),
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
// 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',
|
||||
@@ -83,45 +73,6 @@ export default tseslint.config(
|
||||
'no-useless-escape': 'warn',
|
||||
'no-useless-assignment': 'warn',
|
||||
'preserve-caught-error': 'warn',
|
||||
|
||||
// --- Added later as warnings; lint:warnings freezes what exists and fails on each new one ---
|
||||
|
||||
// A promise nobody awaits or catches loses its failure, and an async
|
||||
// handler where a void one is expected (onClick={async () => ...})
|
||||
// rejects into nowhere.
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-misused-promises': ['warn', { checksVoidReturn: { attributes: true } }],
|
||||
'trek/no-swallowed-catch': 'warn',
|
||||
// A `!` silences the null the type admits instead of handling it.
|
||||
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||
// Whole-store subscriptions; the message points to useShallow.
|
||||
'trek/store-without-selector': 'warn',
|
||||
// window.__dragData, window.__addToast and window.dispatchEvent buses.
|
||||
'trek/no-window-globals': 'warn',
|
||||
'no-console': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Size and branching, in app code only. ESLint's defaults for complexity
|
||||
// (20) and max-depth (4). Five parameters: a hook with more takes an
|
||||
// options object. A function body may hold 120 lines in a .ts file, which
|
||||
// is logic only; a .tsx component also holds its markup, which is long by
|
||||
// nature, so it gets 300, still well under the god components this is
|
||||
// meant to stop (LoginPage was one function of 1300 lines).
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
ignores: ['src/**/*.test.{ts,tsx}'],
|
||||
rules: {
|
||||
complexity: ['warn', 20],
|
||||
'max-depth': ['warn', 4],
|
||||
'max-params': ['warn', 5],
|
||||
'max-lines-per-function': ['warn', { max: 120, skipBlankLines: true, skipComments: true, IIFEs: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.tsx'],
|
||||
ignores: ['src/**/*.test.tsx'],
|
||||
rules: {
|
||||
'max-lines-per-function': ['warn', { max: 300, skipBlankLines: true, skipComments: true, IIFEs: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+3
-16
@@ -1,9 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "4.3.3",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -20,20 +17,10 @@
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint .",
|
||||
"lint:check": "eslint .",
|
||||
"lint:warnings": "node scripts/eslint-ratchet.mjs",
|
||||
"lint:pages": "node scripts/check-page-pattern.mjs",
|
||||
"lint:rtl": "node scripts/rtl-lint.mjs",
|
||||
"lint:size": "node scripts/size-lint.mjs",
|
||||
"lint:format": "node scripts/format-lint.mjs",
|
||||
"lint:layers": "node scripts/layer-lint.mjs",
|
||||
"lint:offline": "node scripts/offline-lint.mjs",
|
||||
"lint:dup": "node scripts/dup-lint.mjs",
|
||||
"lint:pairs": "node scripts/pairs-lint.mjs",
|
||||
"lint:skips": "node scripts/skip-lint.mjs",
|
||||
"lint:strict": "node scripts/strict-lint.mjs",
|
||||
"lint:i18n-keys": "node scripts/i18n-keys.mjs",
|
||||
"check:gl-split": "node scripts/check-gl-split.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",
|
||||
"help:media": "node e2e/help/run.mjs",
|
||||
@@ -67,6 +54,7 @@
|
||||
"lucide-react": "^0.577.0",
|
||||
"mapbox-gl": "^3.31.0",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"marked": "^18.0.13",
|
||||
"plyr": "^3.8.4",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
@@ -78,14 +66,13 @@
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"tz-lookup": "^6.1.25",
|
||||
"zod": "^4.6.5",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@jscpd/core": "^4.2.5",
|
||||
"@jscpd/tokenizer": "^4.2.6",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@testing-library/jest-dom": "^6.10.0",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1,29 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* lint:dup: keeps copied code from coming back.
|
||||
*
|
||||
* Most features have a desktop view and a phone view, and each used to carry
|
||||
* its own copy of the feature's logic, so a fix reached one of them and the
|
||||
* other kept the bug. The logic now lives in one shared hook or module per
|
||||
* feature, and this check holds the copies that remain to what they are. It
|
||||
* counts, per file under src/, the lines inside a block of code that repeats
|
||||
* elsewhere (or in the same file), at the thresholds SonarCloud uses on a
|
||||
* pull request. The files that still hold such lines are listed in
|
||||
* scripts/dup-baseline.json with their count, and a file may hold no more
|
||||
* than its entry (a file without an entry holds none). An entry above what
|
||||
* its file holds now, or for a file that is gone, fails as well until
|
||||
* --update lowers it.
|
||||
*
|
||||
* npm run lint:dup check against the baseline (CI)
|
||||
* npm run lint:dup -- --list print every copied block with its other side
|
||||
* npm run lint:dup -- --update lower the baseline to what the files hold now;
|
||||
* it never raises an entry
|
||||
*
|
||||
* The measuring lives in scripts/lib/dup.mjs.
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { check } from './lib/dup.mjs';
|
||||
import { runCli } from './lib/ratchet.mjs';
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
await runCli('lint:dup', (args) => check({ root, update: args.includes('--update'), list: args.includes('--list') }));
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 404,
|
||||
"@typescript-eslint/no-misused-promises": 876,
|
||||
"no-console": 88,
|
||||
"complexity": 231,
|
||||
"@typescript-eslint/no-explicit-any": 148,
|
||||
"trek/no-swallowed-catch": 133,
|
||||
"max-params": 35,
|
||||
"trek/store-without-selector": 69,
|
||||
"react-hooks/refs": 561,
|
||||
"react-hooks/set-state-in-effect": 197,
|
||||
"@typescript-eslint/no-floating-promises": 167,
|
||||
"react-hooks/exhaustive-deps": 121,
|
||||
"@typescript-eslint/no-unused-vars": 155,
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 133,
|
||||
"react-hooks/static-components": 78,
|
||||
"react-hooks/preserve-manual-memoization": 15,
|
||||
"no-useless-assignment": 7,
|
||||
"no-empty": 19,
|
||||
"trek/no-window-globals": 42,
|
||||
"react-refresh/only-export-components": 46,
|
||||
"unused-disable-directive": 6,
|
||||
"max-depth": 21,
|
||||
"react-hooks/void-use-memo": 1,
|
||||
"@typescript-eslint/no-unused-expressions": 2,
|
||||
"react-hooks/purity": 2,
|
||||
"preserve-caught-error": 53,
|
||||
"no-useless-escape": 1
|
||||
},
|
||||
"suppressed": {
|
||||
"@typescript-eslint/no-explicit-any": 40,
|
||||
"react-hooks/exhaustive-deps": 46
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 491,
|
||||
"@typescript-eslint/no-non-null-assertion": 1949,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
"trek/no-window-globals": 332,
|
||||
"trek/no-swallowed-catch": 14,
|
||||
"react-hooks/globals": 5,
|
||||
"react-hooks/refs": 22,
|
||||
"@typescript-eslint/no-this-alias": 1,
|
||||
"trek/store-without-selector": 3,
|
||||
"@typescript-eslint/no-unsafe-function-type": 2,
|
||||
"no-console": 2,
|
||||
"react-refresh/only-export-components": 1
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* lint:warnings: ESLint warnings may only go down.
|
||||
*
|
||||
* Most rules in eslint.config.mjs are warnings, set that way to keep CI green
|
||||
* on code that was never linted, and a warning never fails a build. So they
|
||||
* piled up: unused variables hiding dead props, missing effect dependencies
|
||||
* of the kind that has already shown stale money. This runs ESLint over the
|
||||
* client and counts the warnings per rule, separately for app code and
|
||||
* tests, and the messages an eslint-disable comment silenced as a third
|
||||
* group. Each count may not pass its entry in scripts/eslint-baseline.json,
|
||||
* and an entry above its count fails as well until --update lowers it, so a
|
||||
* fixed warning cannot come back unseen.
|
||||
* Any error fails as well, so the check can stand in for `npm run lint:check`,
|
||||
* and so does a block comment that configures a rule inline (such as one that
|
||||
* turns no-explicit-any off): it leaves no message to count, so it would
|
||||
* otherwise be the free way past the ratchet.
|
||||
*
|
||||
* npm run lint:warnings check against the baseline (CI)
|
||||
* npm run lint:warnings -- --update lower the baseline to today's counts;
|
||||
* it never raises an entry
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { check } from './lib/eslint-warnings.mjs';
|
||||
import { runCli } from './lib/ratchet.mjs';
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
await runCli('lint:warnings', (args) => check({ root, update: args.includes('--update') }));
|
||||
@@ -1,927 +0,0 @@
|
||||
{
|
||||
"pairs": [
|
||||
{
|
||||
"feature": "Accommodation (stay) create/edit",
|
||||
"hooks": ["src/components/Planner/stayFormModel.ts"],
|
||||
"desktop": ["src/components/Planner/DayDetailPanel.tsx", "src/components/Planner/useDayDetail.ts"],
|
||||
"mobile": ["src/mobile/screens/trip/sheets/MAccommodationSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Add place to collection",
|
||||
"hooks": ["src/components/Collections/addPlaceModel.ts", "src/components/Collections/useAddPlaceToCollection.ts"],
|
||||
"desktop": ["src/components/Collections/AddPlaceToCollectionModal.tsx"],
|
||||
"mobile": ["src/mobile/screens/collections/MCollAddSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Addon manager",
|
||||
"hooks": [
|
||||
"src/components/Admin/addons/addonModel.ts",
|
||||
"src/components/Admin/addons/useAddonManager.ts",
|
||||
"src/components/Admin/addons/useLlmParsingConfig.ts"
|
||||
],
|
||||
"desktop": ["src/components/Admin/AddonManager.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminAddonManager.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Admin notification preference matrix",
|
||||
"hooks": ["src/components/Admin/useAdminNotificationMatrix.ts", "src/pages/admin/AdminPage.constants.ts"],
|
||||
"desktop": ["src/pages/admin/AdminNotificationsPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminNotifyMatrix.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Admin notifications tab",
|
||||
"hooks": ["src/components/Admin/useAdminNotificationSettings.ts"],
|
||||
"desktop": ["src/pages/admin/AdminNotificationsTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminNotificationsSection.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Admin page shell",
|
||||
"hooks": ["src/pages/admin/useAdmin.ts"],
|
||||
"desktop": ["src/pages/AdminPage.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdmin.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Admin settings tab: trekApiModel",
|
||||
"hooks": ["src/components/Admin/trekApiModel.ts"],
|
||||
"desktop": ["src/pages/admin/TrekApiCard.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MApiProviderBlocks.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Admin settings tab: useAdminSettingsActions",
|
||||
"hooks": ["src/components/Admin/useAdminSettingsActions.ts"],
|
||||
"desktop": ["src/pages/admin/AdminSettingsTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminSettingsSection.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Admin users invite status",
|
||||
"hooks": ["src/pages/admin/adminModel.ts"],
|
||||
"desktop": ["src/pages/admin/AdminUsersTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminUsersSection.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Atlas country/region action sheet",
|
||||
"hooks": ["src/pages/atlas/useAtlasCountryActions.ts"],
|
||||
"desktop": ["src/pages/AtlasPage.tsx"],
|
||||
"mobile": ["src/mobile/screens/atlas/MAtlasCountryPopup.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Atlas page: atlasModel",
|
||||
"hooks": ["src/pages/atlas/atlasModel.ts"],
|
||||
"desktop": [
|
||||
"src/components/Atlas/AtlasCountryPlaces.tsx",
|
||||
"src/components/shared/CountryFlag.tsx",
|
||||
"src/pages/AtlasPage.tsx"
|
||||
],
|
||||
"mobile": ["src/mobile/screens/atlas/MAtlas.tsx", "src/mobile/screens/atlas/MAtlasBucketSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Atlas page: useAtlas",
|
||||
"hooks": ["src/pages/atlas/useAtlas.ts"],
|
||||
"desktop": ["src/pages/AtlasPage.tsx"],
|
||||
"mobile": ["src/mobile/screens/atlas/MAtlas.tsx", "src/mobile/screens/atlas/atlasController.ts"]
|
||||
},
|
||||
{
|
||||
"feature": "Audit log",
|
||||
"hooks": ["src/components/Admin/useAuditLog.ts"],
|
||||
"desktop": ["src/components/Admin/AuditLogPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminAuditLogPanel.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Backup panel",
|
||||
"hooks": ["src/components/Admin/useBackupAdmin.ts"],
|
||||
"desktop": ["src/components/Admin/BackupPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminBackupPanel.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Booking forms: linked expense and file attach",
|
||||
"hooks": ["src/components/Planner/useBookingExpenseIntent.ts"],
|
||||
"desktop": ["src/components/Planner/ReservationModal.tsx", "src/components/Planner/TransportModal.tsx"],
|
||||
"mobile": [
|
||||
"src/mobile/screens/trip/sheets/MReservationSheet.tsx",
|
||||
"src/mobile/screens/trip/sheets/MTransportFormSheet.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Bookings and transports lists",
|
||||
"hooks": ["src/components/Planner/bookings/bookingsModel.ts"],
|
||||
"desktop": [
|
||||
"src/components/Planner/DayDetailPanel.tsx",
|
||||
"src/components/Planner/DayPlanSidebar.tsx",
|
||||
"src/components/Planner/PlaceInspector.tsx",
|
||||
"src/components/Planner/ReservationModal.tsx",
|
||||
"src/components/Planner/ReservationsPanel.tsx",
|
||||
"src/components/Planner/TransportModal.tsx",
|
||||
"src/components/Planner/bookings/BookingCard.tsx",
|
||||
"src/components/Planner/bookings/BookingDetailDialog.tsx",
|
||||
"src/components/Planner/bookings/BookingDetailHost.tsx",
|
||||
"src/components/Planner/bookings/BookingDialogShell.tsx",
|
||||
"src/components/Planner/bookings/BookingsHeader.tsx",
|
||||
"src/components/Planner/bookings/BookingsList.tsx",
|
||||
"src/components/Planner/bookings/BookingsTimeline.tsx",
|
||||
"src/components/Planner/bookings/bookingFacts.ts",
|
||||
"src/components/Planner/bookings/bookingParts.tsx",
|
||||
"src/components/Planner/bookings/timelineModel.ts",
|
||||
"src/components/Planner/bookings/useReservationListFilter.ts",
|
||||
"src/components/Planner/planCosts.ts",
|
||||
"src/pages/sharedTrip/SharedBookingsView.tsx",
|
||||
"src/pages/sharedTrip/SharedDayCard.tsx"
|
||||
],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MBookingsTab.tsx", "src/mobile/screens/trip/tabs/MTransportsTab.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Bottom nav context-aware create action",
|
||||
"hooks": ["src/components/Layout/useCreateAction.ts"],
|
||||
"desktop": ["src/components/Layout/BottomNav.tsx"],
|
||||
"mobile": ["src/mobile/components/MBottomNav.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Bulk save selected places to a collection",
|
||||
"hooks": ["src/components/Collections/useSaveTripPlacesToList.ts"],
|
||||
"desktop": ["src/components/Collections/SaveTripPlacesToListModal.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Category manager",
|
||||
"hooks": ["src/components/Admin/useCategoryAdmin.ts"],
|
||||
"desktop": ["src/components/Admin/CategoryManager.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminCategoryManager.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Collab chat: collabModel",
|
||||
"hooks": ["src/components/Collab/collabModel.ts"],
|
||||
"desktop": [
|
||||
"src/components/Collab/CollabChatMessages.tsx",
|
||||
"src/components/Collab/CollabPolls.tsx",
|
||||
"src/components/Collab/useCollabChat.ts"
|
||||
],
|
||||
"mobile": [
|
||||
"src/mobile/screens/trip/tabs/MCollabChat.tsx",
|
||||
"src/mobile/screens/trip/tabs/MCollabNotes.tsx",
|
||||
"src/mobile/screens/trip/tabs/MCollabPolls.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Collab chat: useCollabChatData",
|
||||
"hooks": ["src/components/Collab/useCollabChatData.ts"],
|
||||
"desktop": ["src/components/Collab/useCollabChat.ts"],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MCollabChat.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Collab notes",
|
||||
"hooks": ["src/components/Collab/useCollabNotesData.ts"],
|
||||
"desktop": ["src/components/Collab/CollabNotes.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MCollabNotes.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Collab polls",
|
||||
"hooks": ["src/components/Collab/useCollabPolls.ts"],
|
||||
"desktop": ["src/components/Collab/CollabPolls.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MCollabPolls.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Collection list editor",
|
||||
"hooks": ["src/components/Collections/useListEditor.ts"],
|
||||
"desktop": ["src/components/Collections/ListEditorModal.tsx"],
|
||||
"mobile": ["src/mobile/screens/collections/MCollEditSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Collection place detail",
|
||||
"hooks": ["src/components/Collections/useCollectionPlaceForm.ts"],
|
||||
"desktop": ["src/components/Collections/CollectionPlaceDetail.tsx"],
|
||||
"mobile": ["src/mobile/screens/collections/MCollPlaceSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Collection sharing",
|
||||
"hooks": ["src/components/Collections/useCollectionSharing.ts"],
|
||||
"desktop": ["src/components/Collections/ShareCollectionModal.tsx"],
|
||||
"mobile": ["src/mobile/screens/collections/MCollShareSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Collections page: collectionsModel",
|
||||
"hooks": ["src/pages/collections/collectionsModel.ts"],
|
||||
"desktop": [
|
||||
"src/components/Collections/AddPlaceToCollectionModal.tsx",
|
||||
"src/components/Collections/CollectionFilterBar.tsx",
|
||||
"src/components/Collections/CollectionMap.tsx",
|
||||
"src/components/Collections/CollectionPicker.tsx",
|
||||
"src/components/Collections/CollectionPlaceDetail.tsx",
|
||||
"src/components/Collections/ListEditorModal.tsx",
|
||||
"src/components/Collections/MSaveToCollectionSheet.tsx",
|
||||
"src/components/Collections/StatusBadge.tsx"
|
||||
],
|
||||
"mobile": [
|
||||
"src/mobile/screens/collections/MCollAddSheet.tsx",
|
||||
"src/mobile/screens/collections/MCollEditSheet.tsx",
|
||||
"src/mobile/screens/collections/MCollPlaceRow.tsx",
|
||||
"src/mobile/screens/collections/MCollPlaceSheet.tsx",
|
||||
"src/mobile/screens/collections/MCollections.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Collections page: useCollections",
|
||||
"hooks": ["src/pages/collections/useCollections.ts"],
|
||||
"desktop": ["src/pages/CollectionsPage.tsx"],
|
||||
"mobile": ["src/mobile/screens/collections/MCollections.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Copy collection places to a trip",
|
||||
"hooks": ["src/components/Collections/useTripCopyPicker.ts"],
|
||||
"desktop": ["src/components/Collections/CopyToTripModal.tsx"],
|
||||
"mobile": ["src/mobile/screens/collections/MCollTripPickerSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Costs/budget ledger: costsModel",
|
||||
"hooks": ["src/components/Budget/costsModel.ts"],
|
||||
"desktop": ["src/components/Budget/CostsPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/sheets/MCostSheet.tsx", "src/mobile/screens/trip/tabs/MCostsTab.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Costs/budget ledger: useCostsLedger, useSettlementForm",
|
||||
"hooks": ["src/components/Budget/useCostsLedger.ts", "src/components/Budget/useSettlementForm.ts"],
|
||||
"desktop": ["src/components/Budget/CostsPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MCostsTab.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Create/edit trip form",
|
||||
"hooks": ["src/components/Trips/tripFormModel.ts", "src/components/Trips/useTripCoverPicker.ts"],
|
||||
"desktop": ["src/components/Trips/TripFormModal.tsx"],
|
||||
"mobile": ["src/mobile/screens/dashboard/MNewTripSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Dashboard page",
|
||||
"hooks": ["src/pages/dashboard/useDashboard.ts"],
|
||||
"desktop": ["src/pages/DashboardPage.tsx"],
|
||||
"mobile": ["src/mobile/screens/dashboard/MDashboard.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Dashboard residuals: dashboardModel",
|
||||
"hooks": ["src/pages/dashboard/dashboardModel.ts"],
|
||||
"desktop": ["src/pages/DashboardPage.tsx"],
|
||||
"mobile": ["src/mobile/screens/dashboard/MDashWidgets.tsx", "src/mobile/screens/dashboard/MDashboard.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Dashboard residuals: useDashboardPlugins",
|
||||
"hooks": ["src/components/Dashboard/useDashboardPlugins.ts"],
|
||||
"desktop": ["src/pages/DashboardPage.tsx"],
|
||||
"mobile": ["src/mobile/screens/dashboard/MDashboard.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Dashboard widgets: useCollectionLists",
|
||||
"hooks": ["src/components/Dashboard/useCollectionLists.ts"],
|
||||
"desktop": ["src/components/Dashboard/CollectionsWidget.tsx"],
|
||||
"mobile": ["src/mobile/screens/dashboard/MDashWidgets.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Dashboard widgets: useCurrencyConverter, useDashboardTimezones",
|
||||
"hooks": [
|
||||
"src/components/Dashboard/useCurrencyConverter.ts",
|
||||
"src/components/Dashboard/useDashboardTimezones.ts"
|
||||
],
|
||||
"desktop": ["src/pages/DashboardPage.tsx"],
|
||||
"mobile": ["src/mobile/screens/dashboard/MDashWidgets.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Day detail",
|
||||
"hooks": ["src/components/Planner/dayDetailModel.ts"],
|
||||
"desktop": ["src/components/Planner/DayDetailPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts", "src/mobile/screens/trip/sheets/MDaySheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Day note create/edit/delete",
|
||||
"hooks": ["src/hooks/useDayNotes.ts"],
|
||||
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/sheets/MDaySheet.tsx", "src/mobile/screens/trip/sheets/MNoteSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Day plan reorder",
|
||||
"hooks": ["src/utils/mergedOrder.ts"],
|
||||
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
|
||||
},
|
||||
{
|
||||
"feature": "Day route optimize + Google Maps / CoMaps export",
|
||||
"hooks": ["src/components/Planner/dayRoute.ts"],
|
||||
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts", "src/mobile/screens/trip/sheets/MDaySheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Day weather chip anchor + weather icon map: dayWeather",
|
||||
"hooks": ["src/utils/dayWeather.ts"],
|
||||
"desktop": ["src/components/Planner/DayPlanSidebar.tsx", "src/pages/TripPlannerPage.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts", "src/mobile/screens/trip/sheets/MDaySheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Day weather chip anchor + weather icon map: weatherIcons",
|
||||
"hooks": ["src/components/Weather/weatherIcons.ts"],
|
||||
"desktop": ["src/components/Planner/DayDetailPanel.tsx", "src/components/Weather/WeatherWidget.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/plan/MPlanTimeline.tsx", "src/mobile/screens/trip/sheets/MDaySheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Default user settings",
|
||||
"hooks": ["src/components/Admin/useDefaultUserSettings.ts"],
|
||||
"desktop": ["src/components/Admin/DefaultUserSettingsTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminDefaultUserSettings.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Dev test notifications panel",
|
||||
"hooks": ["src/components/Admin/useDevNotifications.ts"],
|
||||
"desktop": ["src/components/Admin/DevNotificationsPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminDevNotificationsPanel.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Document sync leftovers: docSyncModel",
|
||||
"hooks": ["src/components/Files/docsync/docSyncModel.ts"],
|
||||
"desktop": ["src/components/Files/docsync/DocSyncFlow.tsx", "src/components/Files/docsync/DocSyncPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MDocSyncSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Document sync leftovers: useScopePicker",
|
||||
"hooks": ["src/components/Files/docsync/useScopePicker.ts"],
|
||||
"desktop": ["src/components/Files/docsync/DocSyncScopeModal.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MDocSyncSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Expense editor",
|
||||
"hooks": ["src/components/Budget/useExpenseForm.ts"],
|
||||
"desktop": ["src/components/Budget/CostsPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/sheets/MCostSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "GitHub releases panel",
|
||||
"hooks": ["src/components/Admin/useGithubReleases.ts", "src/utils/releaseNotes.ts"],
|
||||
"desktop": ["src/components/Admin/GitHubPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminGitHubPanel.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Google/Naver shared-list import",
|
||||
"hooks": ["src/components/Planner/useListImport.ts"],
|
||||
"desktop": ["src/components/Planner/usePlacesSidebar.ts"],
|
||||
"mobile": ["src/mobile/screens/trip/sheets/ImpListStep.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "GPX/KML/KMZ file import into places",
|
||||
"hooks": ["src/components/Planner/usePlacesFileImport.ts"],
|
||||
"desktop": ["src/components/Planner/FileImportModal.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/sheets/ImpFileStep.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Journey detail page: JourneyDetailPage.helpers",
|
||||
"hooks": ["src/pages/journeyDetail/JourneyDetailPage.helpers.ts"],
|
||||
"desktop": [
|
||||
"src/components/Journey/JourneyDetailPageAddTripDialog.tsx",
|
||||
"src/components/Journey/JourneyDetailPageEntryCard.tsx",
|
||||
"src/components/Journey/JourneyDetailPageEntryEditor.tsx",
|
||||
"src/components/Journey/JourneyDetailPageGalleryView.tsx",
|
||||
"src/components/Journey/JourneyDetailPageMapView.tsx",
|
||||
"src/components/Journey/JourneyDetailPagePhotoGrid.tsx",
|
||||
"src/components/Journey/JourneyDetailPageProviderPicker.tsx",
|
||||
"src/components/Journey/JourneyDetailPageSettingsDialog.tsx",
|
||||
"src/components/Journey/JourneyEntryCover.tsx",
|
||||
"src/components/Journey/MobileEntryView.tsx",
|
||||
"src/pages/JourneyDetailPage.tsx",
|
||||
"src/pages/JourneyPublicPage.tsx",
|
||||
"src/pages/journeyPublic/useJourneyPublic.ts"
|
||||
],
|
||||
"mobile": [
|
||||
"src/mobile/screens/journey/MJourneyCreateSheet.tsx",
|
||||
"src/mobile/screens/journey/MJourneyDetail.tsx",
|
||||
"src/mobile/screens/journey/MJourneyEntrySheet.tsx",
|
||||
"src/mobile/screens/journey/MJourneySettingsSheet.tsx",
|
||||
"src/mobile/screens/journey/mobileJourneyMeta.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Journey detail page: useJourneyDetail",
|
||||
"hooks": ["src/pages/journeyDetail/useJourneyDetail.ts"],
|
||||
"desktop": ["src/pages/JourneyDetailPage.tsx"],
|
||||
"mobile": ["src/mobile/screens/journey/MJourneyDetail.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Journey detail screen: useGalleryUpload",
|
||||
"hooks": ["src/components/Journey/useGalleryUpload.ts"],
|
||||
"desktop": ["src/components/Journey/JourneyDetailPageGalleryView.tsx"],
|
||||
"mobile": ["src/mobile/screens/journey/MJourneyDetail.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Journey detail screen: useJourneyCarouselSync",
|
||||
"hooks": ["src/components/Journey/useJourneyCarouselSync.ts"],
|
||||
"desktop": ["src/components/Journey/MobileMapTimeline.tsx"],
|
||||
"mobile": ["src/mobile/screens/journey/MJourneyDetail.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Journey entry editor",
|
||||
"hooks": ["src/components/Journey/useJourneyEntryForm.ts"],
|
||||
"desktop": ["src/components/Journey/JourneyDetailPageEntryEditor.tsx"],
|
||||
"mobile": ["src/mobile/screens/journey/MJourneyEntrySheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Journey list page",
|
||||
"hooks": ["src/pages/journey/useJourney.ts"],
|
||||
"desktop": ["src/pages/JourneyPage.tsx"],
|
||||
"mobile": ["src/mobile/screens/journey/MJourney.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Journey settings: useJourneySettings",
|
||||
"hooks": ["src/components/Journey/useJourneySettings.ts"],
|
||||
"desktop": [
|
||||
"src/components/Journey/JourneyDetailPageAddTripDialog.tsx",
|
||||
"src/components/Journey/JourneyDetailPageSettingsDialog.tsx"
|
||||
],
|
||||
"mobile": ["src/mobile/screens/journey/MJourneySettingsSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Journey settings: useJourneyShareLink",
|
||||
"hooks": ["src/components/Journey/useJourneyShareLink.ts"],
|
||||
"desktop": ["src/components/Journey/JourneyShareSection.tsx"],
|
||||
"mobile": ["src/mobile/screens/journey/MJourneySettingsSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Lists sub-tab (packing/todo) persistence",
|
||||
"hooks": ["src/hooks/useListsSubTab.ts"],
|
||||
"desktop": ["src/pages/tripPlanner/ListsContainer.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/MTripShell.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "MCP tokens / OAuth sessions admin",
|
||||
"hooks": ["src/components/Admin/useMcpTokensAdmin.ts"],
|
||||
"desktop": ["src/components/Admin/AdminMcpTokensPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminMcpTokensPanel.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Media lightbox",
|
||||
"hooks": ["src/components/Files/useMediaLightbox.ts"],
|
||||
"desktop": ["src/components/Files/FileManagerImageLightbox.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MFileLightbox.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Notifications list row actions",
|
||||
"hooks": ["src/components/Notifications/useNotificationItemActions.ts"],
|
||||
"desktop": ["src/components/Notifications/InAppNotificationItem.tsx"],
|
||||
"mobile": ["src/mobile/screens/notifications/MNotificationRow.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Notifications page",
|
||||
"hooks": ["src/pages/inAppNotifications/useInAppNotifications.ts"],
|
||||
"desktop": ["src/pages/InAppNotificationsPage.tsx"],
|
||||
"mobile": ["src/mobile/screens/notifications/MNotifications.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "OAuth scope group picker",
|
||||
"hooks": ["src/components/OAuth/useScopeSelection.ts"],
|
||||
"desktop": ["src/components/OAuth/ScopeGroupPicker.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MScopeGroupPicker.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Packing bag card and bag weight summary",
|
||||
"hooks": ["src/components/Packing/useBagCardEditor.ts"],
|
||||
"desktop": ["src/components/Packing/PackingListPanelBagCard.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MBagsSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Packing list: packingListModel",
|
||||
"hooks": ["src/components/Packing/packingListModel.ts"],
|
||||
"desktop": [
|
||||
"src/components/Packing/packingPrintDocument.ts",
|
||||
"src/components/Packing/usePackingExport.ts",
|
||||
"src/components/Packing/usePackingListPanel.ts"
|
||||
],
|
||||
"mobile": [
|
||||
"src/mobile/screens/trip/tabs/MBagsSheet.tsx",
|
||||
"src/mobile/screens/trip/tabs/MPackItemSheet.tsx",
|
||||
"src/mobile/screens/trip/tabs/MPackingListTab.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Packing list: usePackingBags, usePackingCategoryAssignees, usePackingItemActions, usePackingTemplates",
|
||||
"hooks": [
|
||||
"src/components/Packing/usePackingBags.ts",
|
||||
"src/components/Packing/usePackingCategoryAssignees.ts",
|
||||
"src/components/Packing/usePackingItemActions.ts",
|
||||
"src/components/Packing/usePackingTemplates.ts"
|
||||
],
|
||||
"desktop": ["src/components/Packing/usePackingListPanel.ts"],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MPackingListTab.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Packing list: usePackingImport",
|
||||
"hooks": ["src/components/Packing/usePackingImport.ts"],
|
||||
"desktop": ["src/components/Packing/usePackingListPanel.ts"],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MPackingImportSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Packing template manager",
|
||||
"hooks": ["src/components/Admin/usePackingTemplateAdmin.ts"],
|
||||
"desktop": ["src/components/Admin/PackingTemplateManager.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminPackingTemplateManager.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Per-leg travel mode and connector mode menu",
|
||||
"hooks": ["src/components/Planner/useLegModeActions.ts"],
|
||||
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/plan/MPlanTimeline.tsx", "src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
|
||||
},
|
||||
{
|
||||
"feature": "Permissions panel",
|
||||
"hooks": ["src/components/Admin/usePermissionsAdmin.ts"],
|
||||
"desktop": ["src/components/Admin/PermissionsPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminPermissionsPanel.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Place add/edit form and maps search: usePlaceForm",
|
||||
"hooks": ["src/components/Planner/usePlaceForm.ts"],
|
||||
"desktop": ["src/components/Planner/PlaceFormModal.tsx"],
|
||||
"mobile": [
|
||||
"src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx",
|
||||
"src/mobile/screens/trip/sheets/PlCategoryPicker.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Place add/edit form and maps search: usePlaceSearch",
|
||||
"hooks": ["src/components/Planner/usePlaceSearch.ts"],
|
||||
"desktop": ["src/components/Planner/PlaceFormModal.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/sheets/PlPlaceSearch.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Place detail actions",
|
||||
"hooks": ["src/components/Planner/usePlaceActions.ts"],
|
||||
"desktop": [
|
||||
"src/components/Planner/PlaceInspector.tsx",
|
||||
"src/components/shared/PlaceAvatarUpload.tsx",
|
||||
"src/pages/tripPlanner/useTripPlannerPage.ts"
|
||||
],
|
||||
"mobile": ["src/mobile/screens/trip/sheets/MPlaceSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Places pool list",
|
||||
"hooks": ["src/components/Planner/usePlacesPool.ts"],
|
||||
"desktop": ["src/components/Planner/PlacesSidebarHeader.tsx", "src/components/Planner/usePlacesSidebar.ts"],
|
||||
"mobile": [
|
||||
"src/mobile/screens/trip/places/MPlacesBrowser.tsx",
|
||||
"src/mobile/screens/trip/places/MPlacesFilterControls.tsx",
|
||||
"src/mobile/screens/trip/places/MPlacesFilterSheet.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Plugins admin",
|
||||
"hooks": [
|
||||
"src/components/Admin/plugins/pluginModel.ts",
|
||||
"src/components/Admin/plugins/usePluginDetail.ts",
|
||||
"src/components/Admin/plugins/usePluginsAdmin.ts"
|
||||
],
|
||||
"desktop": ["src/components/Admin/AdminPluginsPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminPluginsPanel.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Reservation form incl. hotel accommodation creation",
|
||||
"hooks": ["src/components/Planner/reservationFormModel.ts", "src/components/Planner/useReservationForm.ts"],
|
||||
"desktop": ["src/components/Planner/ReservationModal.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/sheets/MReservationSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Road-trip add-stop draft",
|
||||
"hooks": ["src/components/Roadtrip/useStopDraftForm.ts"],
|
||||
"desktop": ["src/components/Roadtrip/RoadtripStopPopup.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/roadtrip/MRtDraftSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Road-trip stay length draft",
|
||||
"hooks": ["src/components/Roadtrip/useStayDraft.ts"],
|
||||
"desktop": ["src/components/Roadtrip/RoadtripStayModal.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Account",
|
||||
"hooks": ["src/components/Settings/useAccountSettings.ts"],
|
||||
"desktop": ["src/components/Settings/AccountTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MSettingsAccount.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Account / Passkeys",
|
||||
"hooks": ["src/components/Settings/usePasskeys.ts"],
|
||||
"desktop": ["src/components/Settings/PasskeysSection.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MSettingsAccount.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Appearance",
|
||||
"hooks": ["src/components/Settings/appearanceModel.ts", "src/components/Settings/useAppearanceEditor.ts"],
|
||||
"desktop": ["src/components/Settings/AppearanceSettingsTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MSettingsAppearance.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / General/Display",
|
||||
"hooks": ["src/components/Settings/useSettingSaver.ts"],
|
||||
"desktop": ["src/components/Settings/DisplaySettingsTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MSettingsGeneral.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Integrations / AI parsing",
|
||||
"hooks": ["src/components/Settings/useLlmConnection.ts"],
|
||||
"desktop": ["src/components/Settings/LlmConnectionSection.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MLlmConnectionSection.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Integrations / AirTrail",
|
||||
"hooks": ["src/components/Settings/useAirTrailSettings.ts"],
|
||||
"desktop": ["src/components/Settings/AirTrailConnectionSection.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MAirTrailConnectionSection.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Integrations / MCP",
|
||||
"hooks": ["src/components/Settings/useMcpIntegration.ts"],
|
||||
"desktop": ["src/components/Settings/IntegrationsTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MSettingsMcp.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Integrations / Photo providers",
|
||||
"hooks": ["src/components/Settings/usePhotoProviderConnections.ts"],
|
||||
"desktop": ["src/components/Settings/PhotoProvidersSection.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MPhotoProvidersSection.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Map: mapSettingsModel",
|
||||
"hooks": ["src/components/Settings/mapSettingsModel.ts"],
|
||||
"desktop": ["src/components/Admin/DefaultUserSettingsTab.tsx", "src/components/Settings/MapSettingsTab.tsx"],
|
||||
"mobile": [
|
||||
"src/mobile/screens/admin/MAdminDefaultUserSettings.tsx",
|
||||
"src/mobile/screens/settings/MSettingsMap.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Map: useMapSettingsForm",
|
||||
"hooks": ["src/components/Settings/useMapSettingsForm.ts"],
|
||||
"desktop": ["src/components/Settings/MapSettingsTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MSettingsMap.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Notifications",
|
||||
"hooks": ["src/components/Settings/useNotificationSettings.ts"],
|
||||
"desktop": ["src/components/Settings/NotificationsTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MSettingsNotifications.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Plugins: usePluginActivity",
|
||||
"hooks": ["src/components/Settings/usePluginActivity.ts"],
|
||||
"desktop": ["src/components/Settings/PluginActivityPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MSettingsPlugins.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Plugins: usePluginUserSettings",
|
||||
"hooks": ["src/components/Plugins/usePluginUserSettings.ts"],
|
||||
"desktop": ["src/components/Settings/PluginSettingsTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MSettingsPlugins.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings page",
|
||||
"hooks": ["src/pages/settings/useSettings.ts"],
|
||||
"desktop": ["src/pages/SettingsPage.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MSettings.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings shell: visible tabs + integrations gating",
|
||||
"hooks": ["src/components/Settings/useIntegrationGates.ts"],
|
||||
"desktop": ["src/components/Settings/IntegrationsTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MSettingsIntegrations.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Storage backends admin: useBackendForm",
|
||||
"hooks": ["src/components/Admin/storage/useBackendForm.ts"],
|
||||
"desktop": ["src/components/Admin/storage/BackendForm.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminStoragePanel.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Storage backends admin: useStoragePanel",
|
||||
"hooks": ["src/components/Admin/storage/useStoragePanel.ts"],
|
||||
"desktop": ["src/components/Admin/storage/AdminStoragePanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminStoragePanel.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "To-do list and task add/edit form: useTaskForm",
|
||||
"hooks": ["src/components/Todo/useTaskForm.ts"],
|
||||
"desktop": ["src/components/Todo/TodoListPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MTaskSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "To-do list and task add/edit form: useTodoList",
|
||||
"hooks": ["src/components/Todo/useTodoList.ts"],
|
||||
"desktop": ["src/components/Todo/TodoListPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MTodoListTab.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Toast presenter queue",
|
||||
"hooks": ["src/components/shared/useToastQueue.ts"],
|
||||
"desktop": ["src/components/shared/Toast.tsx"],
|
||||
"mobile": ["src/mobile/components/MToastHost.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Tours list filter",
|
||||
"hooks": ["src/components/Tours/tourPresentation.ts"],
|
||||
"desktop": [
|
||||
"src/components/Tours/TourDetailDialog.tsx",
|
||||
"src/components/Tours/ToursSidebar.tsx",
|
||||
"src/components/Tours/planner/TourPlannerPanels.tsx",
|
||||
"src/components/Tours/tourParts.tsx"
|
||||
],
|
||||
"mobile": ["src/mobile/screens/trip/places/MToursSelectionList.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Transport booking detail view: reservationFiles",
|
||||
"hooks": ["src/utils/reservationFiles.ts"],
|
||||
"desktop": [
|
||||
"src/components/Planner/DayPlanSidebarTransportDetailModal.tsx",
|
||||
"src/components/Planner/ReservationsPanel.tsx",
|
||||
"src/components/Planner/bookings/BookingDetailHost.tsx"
|
||||
],
|
||||
"mobile": [
|
||||
"src/mobile/screens/trip/sheets/MTransportSheet.tsx",
|
||||
"src/mobile/screens/trip/tabs/MBookingsTab.tsx",
|
||||
"src/mobile/screens/trip/tabs/MTransportsTab.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Transport booking detail view: transportDetailModel",
|
||||
"hooks": ["src/components/Planner/transportDetailModel.ts"],
|
||||
"desktop": ["src/components/Planner/DayPlanSidebarTransportDetailModal.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/sheets/MTransportSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Transport booking form: bookingFormModel",
|
||||
"hooks": ["src/components/Planner/bookingFormModel.ts"],
|
||||
"desktop": [
|
||||
"src/components/Planner/ReservationModal.tsx",
|
||||
"src/components/Planner/TransportModal.tsx",
|
||||
"src/components/Planner/useBookingFileAttach.ts"
|
||||
],
|
||||
"mobile": [
|
||||
"src/mobile/screens/trip/sheets/MReservationSheet.tsx",
|
||||
"src/mobile/screens/trip/sheets/MTransportFormSheet.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Transport booking form: transportEndpoints, useTransportForm",
|
||||
"hooks": ["src/components/Planner/transportEndpoints.ts", "src/components/Planner/useTransportForm.ts"],
|
||||
"desktop": ["src/components/Planner/TransportModal.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/sheets/MTransportFormSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Trip export",
|
||||
"hooks": ["src/components/Planner/useTripExport.ts"],
|
||||
"desktop": ["src/components/Planner/TripExportModal.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/sheets/MExportSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Trip files: fileActions",
|
||||
"hooks": ["src/components/Files/fileActions.ts"],
|
||||
"desktop": ["src/components/Files/FileManagerAssignModal.tsx", "src/components/Files/useFileManager.ts"],
|
||||
"mobile": [
|
||||
"src/mobile/screens/trip/tabs/MFileLinkSheet.tsx",
|
||||
"src/mobile/screens/trip/tabs/MFileMenuSheet.tsx",
|
||||
"src/mobile/screens/trip/tabs/MFilesTab.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Trip files: fileListRules",
|
||||
"hooks": ["src/components/Files/fileListRules.ts"],
|
||||
"desktop": ["src/components/Files/FileManagerRow.tsx", "src/components/Files/useFileManager.ts"],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MFileLinkSheet.tsx", "src/mobile/screens/trip/tabs/filesModel.ts"]
|
||||
},
|
||||
{
|
||||
"feature": "Trip files: useFileTrash",
|
||||
"hooks": ["src/components/Files/useFileTrash.ts"],
|
||||
"desktop": ["src/components/Files/useFileManager.ts"],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MFileTrashSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Trip loading splash",
|
||||
"hooks": ["src/components/shared/useTripSplashBeats.ts"],
|
||||
"desktop": ["src/components/shared/TripLoadingSplash.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/MTripLoadingSplash.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Trip planner page",
|
||||
"hooks": ["src/pages/tripPlanner/useTripPlanner.ts"],
|
||||
"desktop": ["src/pages/tripPlanner/useTripPlannerPage.ts"],
|
||||
"mobile": ["src/mobile/screens/trip/MTripShell.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Trip sheet wiring: transportEditorOpeners",
|
||||
"hooks": ["src/pages/tripPlanner/transportEditorOpeners.ts"],
|
||||
"desktop": ["src/pages/TripPlannerPage.tsx", "src/pages/tripPlanner/usePlannerDialogs.ts"],
|
||||
"mobile": ["src/mobile/screens/trip/sheets/MTripSheets.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Trip sheet wiring: tripCover, useBookingExpenseEditor",
|
||||
"hooks": ["src/pages/tripPlanner/tripCover.ts", "src/pages/tripPlanner/useBookingExpenseEditor.ts"],
|
||||
"desktop": ["src/pages/TripPlannerPage.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/sheets/MTripSheets.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "User modals/sheets",
|
||||
"hooks": ["src/components/Admin/useAdminUserActions.ts"],
|
||||
"desktop": ["src/pages/admin/AdminUserModals.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminSheets.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Vacay calendar logging logic: useVacayCalendarLogic",
|
||||
"hooks": ["src/components/Vacay/useVacayCalendarLogic.ts"],
|
||||
"desktop": ["src/components/Vacay/VacayCalendar.tsx", "src/components/Vacay/VacayPersons.tsx"],
|
||||
"mobile": ["src/mobile/screens/vacay/useMVacay.ts"]
|
||||
},
|
||||
{
|
||||
"feature": "Vacay calendar logging logic: vacayFormat",
|
||||
"hooks": ["src/components/Vacay/vacayFormat.ts"],
|
||||
"desktop": ["src/components/Vacay/VacayStats.tsx"],
|
||||
"mobile": ["src/mobile/screens/vacay/MVacay.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Vacay fusion invite",
|
||||
"hooks": ["src/components/Vacay/useVacayUserPicker.ts"],
|
||||
"desktop": ["src/components/Vacay/VacayPersons.tsx", "src/components/Vacay/VacaySharedCalendars.tsx"],
|
||||
"mobile": ["src/mobile/screens/vacay/MVacayInviteSheet.tsx", "src/mobile/screens/vacay/MVacayShareSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Vacay page",
|
||||
"hooks": ["src/pages/vacay/useVacay.ts"],
|
||||
"desktop": ["src/pages/VacayPage.tsx"],
|
||||
"mobile": ["src/mobile/screens/vacay/useMVacay.ts"]
|
||||
},
|
||||
{
|
||||
"feature": "Vacay read-only calendar sharing",
|
||||
"hooks": ["src/components/Vacay/useVacayShareActions.ts"],
|
||||
"desktop": ["src/components/Vacay/VacaySharedCalendars.tsx"],
|
||||
"mobile": ["src/mobile/screens/vacay/MVacayShareSheet.tsx", "src/mobile/screens/vacay/useMVacay.ts"]
|
||||
},
|
||||
{
|
||||
"feature": "Vacay settings",
|
||||
"hooks": ["src/components/Vacay/useVacaySettings.ts"],
|
||||
"desktop": ["src/components/Vacay/VacaySettings.tsx"],
|
||||
"mobile": ["src/mobile/screens/vacay/MVacaySettingsSheet.tsx"]
|
||||
}
|
||||
],
|
||||
"mobileOnly": {
|
||||
"src/mobile/screens/admin/MAdminUi.tsx": "Phone only admin UI primitives (button, card, field, input, secret input, sheet frame).",
|
||||
"src/mobile/screens/atlas/MAtlasSearch.tsx": "Phone full screen country search overlay; options, suggestions and selection come through props from MAtlas.",
|
||||
"src/mobile/screens/atlas/MAtlasStatsCard.tsx": "Markup only; renders the AtlasStats that MAtlas passes in.",
|
||||
"src/mobile/screens/collections/MCollCategoryPicker.tsx": "Phone only category dropdown; value and categories come through props from the collection sheets.",
|
||||
"src/mobile/screens/collections/MCollInvites.tsx": "Markup only; the invites and accept and decline come through props from useCollections via MCollections.",
|
||||
"src/mobile/screens/collections/MCollLabelsSheet.tsx": "Phone label sheet; create, update, delete and assign come through props from useCollections.",
|
||||
"src/mobile/screens/collections/MCollLinksEditor.tsx": "Markup only; link rows and the change handler come through props from the collection sheets.",
|
||||
"src/mobile/screens/collections/MCollListPickerSheet.tsx": "Phone list picker sheet; the move or copy action comes through onPick from useCollections.",
|
||||
"src/mobile/screens/collections/MCollSheetKit.tsx": "Phone only sheet UI primitives for the collections screens (header, footer, pill, eyebrow).",
|
||||
"src/mobile/screens/collections/collectionsMobileModel.ts": "Visual constants only the phone collections screens use (category and status specs, swatches, cover gradient).",
|
||||
"src/mobile/screens/dashboard/MUserMenu.tsx": "Phone avatar menu with a three state theme cycle the desktop navbar does not have.",
|
||||
"src/mobile/screens/settings/MConfirmSheet.tsx": "Phone only bottom sheet confirm dialog used across the mobile screens.",
|
||||
"src/mobile/screens/settings/MDawarichConnectionSection.tsx": "Already shares hooks/useDawarichConnection with components/Settings/DawarichConnectionSection.tsx.",
|
||||
"src/mobile/screens/settings/MMobileDashOrder.tsx": "Phone only setting that orders the mobile dashboard blocks; the desktop has no such setting.",
|
||||
"src/mobile/screens/settings/MMobileNavCustomizer.tsx": "Phone only editor for the mobile bottom nav, built on components/Settings/useMobileNavEditor.",
|
||||
"src/mobile/screens/settings/MSetPickerSheet.tsx": "Phone only option picker bottom sheet, a generic UI primitive.",
|
||||
"src/mobile/screens/settings/MSettingsAbout.tsx": "Markup only about card with static links and no state.",
|
||||
"src/mobile/screens/settings/MSettingsOffline.tsx": "Already shares components/Settings/useOfflineSettings with components/Settings/OfflineTab.tsx.",
|
||||
"src/mobile/screens/settings/MSettingsUi.tsx": "Phone only settings UI primitives (card, row, input, button, segments, hint).",
|
||||
"src/mobile/screens/settings/MWebPushCard.tsx": "Already shares hooks/useWebPush with components/Settings/WebPushCard.tsx.",
|
||||
"src/mobile/screens/trip/dockTabs.ts": "Phone only helper that picks which trip tabs get a seat in the mobile dock.",
|
||||
"src/mobile/screens/trip/lib/dayNotes.ts": "Phone only helper that splits a leading time off a day note for the plan timeline.",
|
||||
"src/mobile/screens/trip/map/MMapArea.tsx": "Phone map stage framing; already shares usePoiExplore and useMergedMapPois with the desktop trip planner.",
|
||||
"src/mobile/screens/trip/map/MPlacesFilterPill.tsx": "Phone only map control for the active places filter count, built on the shared MapTogglePill.",
|
||||
"src/mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": "Markup only; categories and the pick handler come through props from MPlacesBrowser.",
|
||||
"src/mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": "Markup only places or tours toggle; value and onChange come through props from MPlacesBrowser.",
|
||||
"src/mobile/screens/trip/places/placesBrowserModel.ts": "Phone pool helpers over the shared utils/placesFilter, placeSearch and plannedPlaces.",
|
||||
"src/mobile/screens/trip/plan/MPlanTimelineRows.tsx": "Markup only timeline rows; row data comes from MPlanTimeline and planTimelineModel.",
|
||||
"src/mobile/screens/trip/plan/planTimelineModel.ts": "Phone plan timeline row builder over the shared utils/dayMerge, dayOrder and dayTourProjection.",
|
||||
"src/mobile/screens/trip/plan/useMPlanDaySwipe.ts": "Phone only touch swipe gesture for switching days.",
|
||||
"src/mobile/screens/trip/plan/useMPlanDragReorder.ts": "Phone only long press drag gesture; the reorder itself runs through the handler it is given.",
|
||||
"src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx": "Markup only road trip rows over the shared Roadtrip models the desktop sidebar rows also use.",
|
||||
"src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx": "Phone road trip tab over the shared roadtripRowModel, stopBookings and nightBookend RoadtripSidebar uses.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": "Markup only; state from useMRtAlternatives, text from the shared alternativeOverlays.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": "Markup only; its corridor state comes through props from useMRtCorridor.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": "Phone corridor search sheet over useMRtCorridor and the planner's shared corridor state.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx": "Read only figures sheet that already shares useHotelBookends, dayWindow and parseAvoid with RoadtripLimitsCard.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtKindSheet.tsx": "Thin sheet over planner.setRoadtripStopKind that already shares STOP_KINDS with StopKindPicker.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx": "Phone only stop detail sheet over the shared roadtripRowModel, stayReading and planner writers.",
|
||||
"src/mobile/screens/trip/roadtrip/stageBadges.ts": "Badge helpers for the phone stage header and alternatives bar, which have no desktop counterpart.",
|
||||
"src/mobile/screens/trip/roadtrip/useMRoadtrip.ts": "Phone stage screen adapter over the shared roadtripRowModel plus a live clock tick.",
|
||||
"src/mobile/screens/trip/roadtrip/useMRtAlternatives.ts": "Tap to preview, confirm to write layer over the planner's shared route alternatives.",
|
||||
"src/mobile/screens/trip/roadtrip/useMRtCorridor.ts": "Phone reach and anchor layer over the planner's shared corridor state.",
|
||||
"src/mobile/screens/trip/sheets/MBookingFields.tsx": "Markup only: the booking code, notes, traveler and route stop fields both phone booking sheets draw.",
|
||||
"src/mobile/screens/trip/sheets/MBookingFilesCosts.tsx": "Phone booking sheet footer that composes PlFileAttach and MLinkedCosts.",
|
||||
"src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": "Phone action sheet for a pool place that calls planner handlers and the shared placeToSaveTarget.",
|
||||
"src/mobile/screens/trip/sheets/MDaysSheet.tsx": "Button based day reorder sheet over planner.handleReorderDays; the desktop reorders by drag.",
|
||||
"src/mobile/screens/trip/sheets/MImportSheet.tsx": "Menu that routes to ImpFileStep and ImpListStep, with no logic of its own.",
|
||||
"src/mobile/screens/trip/sheets/MLinkedCosts.tsx": "Already shares useExpenseLinks with the desktop BookingCostsSection; only markup and a search filter are local.",
|
||||
"src/mobile/screens/trip/sheets/MMehrSheet.tsx": "Phone dock overflow menu that only opens tabs and sheets.",
|
||||
"src/mobile/screens/trip/sheets/MTripSheetUi.tsx": "Phone sheet UI primitives (eyebrow, headers, stat boxes, action circles) and a time display helper.",
|
||||
"src/mobile/screens/trip/sheets/PlFileAttach.tsx": "Markup only; pending files and link actions come through props from the phone form sheets.",
|
||||
"src/mobile/screens/trip/sheets/PlSheetChrome.tsx": "Phone form sheet chrome primitives (header, footer, field classes).",
|
||||
"src/mobile/screens/trip/sheets/PlTimeFields.tsx": "Phone time fields that already share timeCollisions with the desktop PlaceFormModal.",
|
||||
"src/mobile/screens/trip/tabs/MCollabTab.tsx": "Phone collab sub tab router that reuses the desktop CollabLinks component.",
|
||||
"src/mobile/screens/trip/tabs/MListsTab.tsx": "Phone router between the packing and to-do bodies, with no logic of its own.",
|
||||
"src/mobile/screens/trip/tabs/MReceiptScanButton.tsx": "Already shares useReceiptScan and ReceiptScanModal with the desktop CostsPanel.",
|
||||
"src/mobile/screens/trip/tabs/MReservationCardParts.tsx": "Markup only: the title, actions, date, files and delete sheet parts both phone booking tabs draw.",
|
||||
"src/mobile/screens/trip/tabs/MTripTabPanel.tsx": "Phone tab router that mounts the per tab screens and plugin frames, with no logic of its own.",
|
||||
"src/mobile/screens/trip/tabs/listsModel.ts": "Priority colour and label constants only the phone to-do list and task sheet use.",
|
||||
"src/mobile/screens/trip/tabs/tabChrome.tsx": "Phone tab chrome primitives (scroller, section headers, fields, traveler chips, plugin slots).",
|
||||
"src/mobile/screens/trip/tabs/tabModel.ts": "Phone tab prop type and status colour tokens only the phone tabs use.",
|
||||
"src/mobile/screens/trip/tabs/transportsModel.ts": "Phone transport card readers (metadata, day and time cells) for MTransportsTab and MBookingsTab.",
|
||||
"src/mobile/screens/vacay/MVacayMonth.tsx": "Markup only; its day cell colours come from the phone vacayDayModel.",
|
||||
"src/mobile/screens/vacay/vacayDayModel.ts": "Phone day cell colour model for MVacayMonth; the desktop card styles its cells inline."
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* lint:format: new code is formatted with Prettier.
|
||||
*
|
||||
* Client code was never Prettier-checked, and almost every file differs from
|
||||
* what .prettierrc asks for. Formatting all of it at once would bury the
|
||||
* history under one commit, so the files that were unformatted when this
|
||||
* check came in are listed in scripts/format-baseline.json and the list only
|
||||
* shrinks: every file outside it, a new one in particular, must be formatted,
|
||||
* and a listed file leaves the list once it has been formatted. The check
|
||||
* covers .ts, .tsx, .mjs and .css under src/, tests/, e2e/ and scripts/.
|
||||
*
|
||||
* npm run lint:format check against the baseline (CI)
|
||||
* npm run lint:format -- --list print every unformatted file
|
||||
* npm run lint:format -- --update take formatted and deleted files off the list;
|
||||
* it never adds one
|
||||
*
|
||||
* To format a file: npx prettier --write <file>. The comparison lives in
|
||||
* scripts/lib/format.mjs.
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { check } from './lib/format.mjs';
|
||||
import { runCli } from './lib/ratchet.mjs';
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
await runCli('lint:format', (args) =>
|
||||
check({ root, update: args.includes('--update'), list: args.includes('--list') })
|
||||
);
|
||||
@@ -1,603 +0,0 @@
|
||||
/*
|
||||
* lint:i18n-keys: every translation key the client names exists in en.
|
||||
*
|
||||
* `t()` takes any string and returns the key itself when en has no such
|
||||
* entry, so a typo or a key deleted from the locales renders as raw
|
||||
* "budget.addCategory" text and nothing fails. Locale parity cannot see it:
|
||||
* it compares the locales with en, not en with the code. This check reads
|
||||
* src/ (tests aside) and resolves what it finds against shared/src/i18n/en:
|
||||
*
|
||||
* t('x.y'), tHtml('x.y'), tr('x.y') every string literal in the first
|
||||
* argument, so both sides of a
|
||||
* `cond ? 'a.b' : 'c.d'` are checked
|
||||
* <TransHtml html="x.y" /> the markup variant
|
||||
* translateApiError(t, err, 'x.y') the fallback key
|
||||
* labelKey: 'x.y', titleKey="x.y" any property or prop named *Key that
|
||||
* holds a dotted key, the tables
|
||||
* components feed to t() later
|
||||
* const guideKey = (id) => `help.${id}` a key builder (help/registry.ts):
|
||||
* a template returned by a const
|
||||
* named *Key, read as a template key
|
||||
*
|
||||
* A key exists when en declares it, or declares `key.other` (a plural group
|
||||
* whose general form is spelled out). A template whose interpolations can
|
||||
* only produce string literals (t(`roadtrip.window.${a ? 'onRoute' : 'atStop'}`))
|
||||
* is expanded into those keys and each is checked like a literal one; a
|
||||
* literal fallback beside data (`${role ?? 'editor'}`) is checked as a key
|
||||
* too. Any other template literal key such as
|
||||
* t(`budget.category.${id}`), or a prefix with the rest appended
|
||||
* (t('costs.filter.' + f)), cannot be resolved. It is read as a pattern in
|
||||
* which each interpolation stands for one key segment (no dot), and it passes
|
||||
* on its own only when that pattern is narrow: it matches at least one en key
|
||||
* and at most MAX_IMPLICIT_MATCHES of them, the size of an enum such as the
|
||||
* docsync error codes. A pattern that matches nothing fails. A wider one
|
||||
* (`settings.${mode}` reaches every settings key, so a wrong suffix would
|
||||
* pass unseen) and one without a fixed dotted prefix need an entry in
|
||||
* DYNAMIC_ALLOWED, keyed by file and template, saying what bounds the value.
|
||||
* Listing every narrow template instead would be well over a hundred entries
|
||||
* restating their own prefix, and nothing in an entry would be checked that
|
||||
* the bound does not already check. What the bound cannot see is a runtime
|
||||
* value with no key under a narrow prefix: that needs the value's type, which
|
||||
* this text scan does not have, and is the price of not listing them.
|
||||
* A key held in a variable (`t(opt.label)`) is out of reach unless the table
|
||||
* names it in a *Key property.
|
||||
*
|
||||
* The en keys nothing reaches are a ratchet. Besides src/, the search for
|
||||
* references reads server/src (system notice and notification channel keys
|
||||
* the server sends, *Key properties in its registries) and plugin-sdk/src, so
|
||||
* a key the server uses does not read as dead; those references only count as
|
||||
* reached, they are not held to the rules above. The unused keys per en
|
||||
* domain file may not rise above scripts/i18n-unused-baseline.json, and an
|
||||
* entry above its count fails until --update lowers it. A key reached only
|
||||
* through a variable the scan cannot follow is reached by naming it: in a
|
||||
* *Key property of the table, or as a literal t() where the table is read.
|
||||
*
|
||||
* npm run lint:i18n-keys check (CI)
|
||||
* npm run lint:i18n-keys -- --unused also list en keys no literal or pattern reaches
|
||||
* npm run lint:i18n-keys -- --update lower the unused-key baseline; it never raises an entry
|
||||
*/
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
// Plain paths rather than `new URL(..., import.meta.url)`: under the test runner's DOM environment URL is
|
||||
// jsdom's, which fileURLToPath refuses.
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SRC = join(HERE, '..', 'src');
|
||||
const SHARED_SCRIPTS = join(HERE, '..', '..', 'shared', 'scripts');
|
||||
const UNUSED_BASELINE = join(HERE, 'i18n-unused-baseline.json');
|
||||
|
||||
/**
|
||||
* Where else a translation key can be named: the server sends keys for the
|
||||
* client to translate (system notices, notification channels), and the plugin
|
||||
* SDK names the ones plugin surfaces use. Read only to find which en keys are
|
||||
* reached.
|
||||
*/
|
||||
export const REFERENCE_ROOTS = [join(HERE, '..', '..', 'server', 'src'), join(HERE, '..', '..', 'plugin-sdk', 'src')];
|
||||
|
||||
/** How many en keys a template may reach and still pass without an entry below. */
|
||||
export const MAX_IMPLICIT_MATCHES = 30;
|
||||
|
||||
/**
|
||||
* Template keys the check cannot bound by itself: wider than
|
||||
* MAX_IMPLICIT_MATCHES, or without a fixed dotted prefix. Each names the file
|
||||
* it sits in and why the interpolated value can only be a real key; one with
|
||||
* no fixed prefix also lists the keys it resolves to, which must exist. An
|
||||
* entry nothing uses any more fails, so the list cannot rot.
|
||||
*/
|
||||
export const DYNAMIC_ALLOWED = [
|
||||
{
|
||||
file: 'components/Trips/TripShareDialog.tsx',
|
||||
template: '${opt.label}Hint',
|
||||
because:
|
||||
'TripShareDialog appends Hint to each SHARE_OPTIONS label (useTripShare.ts); share.optTravelOnlyHint ' +
|
||||
'and share.optHideImagesHint exist, and the template has no fixed part to match them by',
|
||||
resolves: ['share.optTravelOnlyHint', 'share.optHideImagesHint'],
|
||||
},
|
||||
...[
|
||||
'help.ctx.${id}.${part}',
|
||||
'help.ctx.${id}.bullet.${n}',
|
||||
'help.guide.${id}.${part}',
|
||||
'help.guide.${id}.step.${n}',
|
||||
'help.guide.${id}.tip.${n}',
|
||||
].map((template) => ({
|
||||
file: 'help/registry.ts',
|
||||
template,
|
||||
because:
|
||||
'a key builder over the registered help contexts and guides; src/help/registry.test.ts resolves every ' +
|
||||
'key of every registered entry against en',
|
||||
})),
|
||||
{
|
||||
file: 'mobile/screens/dashboard/MUserMenu.tsx',
|
||||
template: 'settings.${mode}',
|
||||
because: "mode is a ThemeMode from resolveMode(), 'light' | 'dark' | 'auto', and settings.light/dark/auto exist",
|
||||
},
|
||||
...['components/Studio/StudioElementsPanel.tsx', 'components/Studio/StudioInspector.tsx'].map((file) => ({
|
||||
file,
|
||||
template: 'journey.studio.${key}',
|
||||
because: 'key comes from an `as const` tuple of frame and stroke styles written beside the call, typed literals',
|
||||
})),
|
||||
{
|
||||
file: 'mobile/screens/trip/sheets/MBookingFields.tsx',
|
||||
template: 'reservations.${s}',
|
||||
because: "s iterates the literal ['pending', 'confirmed'] as const; reservations.pending/confirmed exist",
|
||||
},
|
||||
...['components/Admin/AdminPluginsPanel.tsx', 'mobile/screens/admin/MAdminPluginsPanel.tsx'].map((file) => ({
|
||||
file,
|
||||
template: 'admin.plugins.perm.${perm}',
|
||||
because:
|
||||
'PermLabel translates a permission only when PERM_KEYS (generated PLUGIN_PERMISSIONS) holds it and shows ' +
|
||||
'the raw code otherwise; the plugin-facts gate keeps the permission keys in step with en',
|
||||
})),
|
||||
...['components/Settings/PhotoProvidersSection.tsx', 'mobile/screens/settings/MPhotoProvidersSection.tsx'].flatMap(
|
||||
(file) =>
|
||||
['memories.${field.label}', 'memories.${field.hint}'].map((template) => ({
|
||||
file,
|
||||
template,
|
||||
because:
|
||||
'field label and hint names come from the provider field rows PhotoProviderSeeder writes, each a ' +
|
||||
'memories.* key in en',
|
||||
}))
|
||||
),
|
||||
{
|
||||
file: 'components/Tours/planner/TourPlannerPanels.tsx',
|
||||
template: 'tours.planner.${role}',
|
||||
because: "roleLabel takes role: 'start' | 'via' | 'end', and tours.planner.start/via/end exist",
|
||||
},
|
||||
];
|
||||
|
||||
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
|
||||
const PREFIX_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)*\.$/;
|
||||
const CALL_RE = /(?<![\w$.])(?:t|tHtml|tr)\(|(?<=[\w$]\.)(?:t|tHtml)\(/g;
|
||||
const HTML_PROP_RE = /<TransHtml\b[^>]*?\bhtml=(["'])([^"'\n]+)\1/g;
|
||||
const API_ERROR_RE = /\btranslateApiError\(\s*t\s*,[^,()]*(?:\([^()]*\))?[^,()]*,\s*(['"])([^'"\n]+)\1/g;
|
||||
const KEY_BUILDER_RE = /\bconst\s+[a-zA-Z]*Key\s*=\s*\([^)]*\)\s*(?::\s*[^=]+?)?=>\s*`((?:\\.|\$\{[^}]*\}|[^`\\])*)`/g;
|
||||
const KEY_PROP_RE = /\b[a-zA-Z]*Key\s*(?::|=\{?)\s*(['"])([a-z][a-zA-Z0-9_]*\.[^'"\s]+)\1/g;
|
||||
|
||||
function walk(dir, files = []) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) walk(path, files);
|
||||
else if (/\.tsx?$/.test(name) && !/\.(?:test|spec)\./.test(name) && !name.endsWith('.d.ts')) files.push(path);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const lineAt = (text, index) => text.slice(0, index).split('\n').length;
|
||||
|
||||
/**
|
||||
* The source of the first argument of the call whose `(` sits at `open`:
|
||||
* everything up to the top-level comma or the closing parenthesis, with
|
||||
* strings, template literals and nested brackets skipped as units.
|
||||
*/
|
||||
export function firstArgument(text, open) {
|
||||
let depth = 0;
|
||||
for (let i = open + 1; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (c === "'" || c === '"' || c === '`') {
|
||||
for (i++; i < text.length && text[i] !== c; i++) {
|
||||
if (text[i] === '\\') i++;
|
||||
else if (c === '`' && text[i] === '$' && text[i + 1] === '{') {
|
||||
// Skip the interpolation, counting its braces.
|
||||
let braces = 0;
|
||||
for (; i < text.length; i++) {
|
||||
if (text[i] === '{') braces++;
|
||||
else if (text[i] === '}' && --braces === 0) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c === '(' || c === '[' || c === '{') depth++;
|
||||
else if (c === ')' || c === ']' || c === '}') {
|
||||
if (depth === 0) return text.slice(open + 1, i);
|
||||
depth--;
|
||||
} else if (c === ',' && depth === 0) return text.slice(open + 1, i);
|
||||
}
|
||||
return text.slice(open + 1);
|
||||
}
|
||||
|
||||
/** The quoted literals and template literals of an argument's source. */
|
||||
export function literalsIn(arg) {
|
||||
const quoted = [];
|
||||
const templates = [];
|
||||
for (const m of arg.matchAll(/(['"])((?:\\.|(?!\1)[^\\\n])*)\1|`((?:\\.|\$\{[^}]*\}|[^`\\])*)`/g)) {
|
||||
if (m[3] !== undefined) templates.push(m[3]);
|
||||
else quoted.push(m[2]);
|
||||
}
|
||||
return { quoted, templates };
|
||||
}
|
||||
|
||||
/**
|
||||
* A template literal's key pattern: its fixed parts, one key segment (no dot)
|
||||
* for each interpolation. Null when it has no fixed dotted prefix.
|
||||
*/
|
||||
export function templatePattern(template) {
|
||||
const parts = template.split(/\$\{[^}]*\}/);
|
||||
if (!/^[a-z][a-zA-Z0-9_]*\./.test(parts[0] ?? '')) return null;
|
||||
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`^${parts.map(escape).join('[^.]+')}$`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The top-level operator positions of an expression: ternary `?` and `:`,
|
||||
* and the `??` and `||` fallbacks. Strings and brackets are skipped as units,
|
||||
* `?.` is optional chaining.
|
||||
*/
|
||||
function topLevelOperators(expr) {
|
||||
const ops = [];
|
||||
let depth = 0;
|
||||
for (let i = 0; i < expr.length; i++) {
|
||||
const c = expr[i];
|
||||
if (c === "'" || c === '"' || c === '`') {
|
||||
for (i++; i < expr.length && expr[i] !== c; i++) if (expr[i] === '\\') i++;
|
||||
} else if (c === '(' || c === '[' || c === '{') depth++;
|
||||
else if (c === ')' || c === ']' || c === '}') depth--;
|
||||
else if (depth > 0) continue;
|
||||
else if ((c === '?' && expr[i + 1] === '?') || (c === '|' && expr[i + 1] === '|')) ops.push({ op: c + c, at: i++ });
|
||||
else if (c === '?' && expr[i + 1] !== '.') ops.push({ op: '?', at: i });
|
||||
else if (c === ':') ops.push({ op: ':', at: i });
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
const QUOTED_RE = /^(['"])((?:\\.|(?!\1)[^\\\n])*)\1$/;
|
||||
|
||||
/** Whether `e` is one parenthesized expression, its first `(` closing at its last character. */
|
||||
function wrapsWhole(e) {
|
||||
if (!e.startsWith('(') || !e.endsWith(')')) return false;
|
||||
let depth = 0;
|
||||
for (let i = 0; i < e.length; i++) {
|
||||
const c = e[i];
|
||||
if (c === "'" || c === '"' || c === '`') {
|
||||
for (i++; i < e.length && e[i] !== c; i++) if (e[i] === '\\') i++;
|
||||
} else if (c === '(') depth++;
|
||||
else if (c === ')' && --depth === 0) return i === e.length - 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The string literals an interpolated expression can produce, and whether
|
||||
* those are all it can produce. `x ? 'a' : y ? 'b' : 'c'` gives a, b and c,
|
||||
* complete; `role ?? 'editor'` gives editor, incomplete (role is data);
|
||||
* `phase` gives nothing. A literal in a condition (`phase === 'start'`) is
|
||||
* never a result.
|
||||
*/
|
||||
export function interpolationResults(expr) {
|
||||
let e = expr.trim();
|
||||
while (wrapsWhole(e)) e = e.slice(1, -1).trim();
|
||||
const ops = topLevelOperators(e);
|
||||
const q = ops.findIndex((o) => o.op === '?');
|
||||
if (q >= 0) {
|
||||
// The matching `:` of the first `?`: nested ternaries in the consequent raise the count.
|
||||
let open = 0;
|
||||
for (const o of ops.slice(q + 1)) {
|
||||
if (o.op === '?') open++;
|
||||
else if (o.op === ':' && open-- === 0) {
|
||||
const a = interpolationResults(e.slice(ops[q].at + 1, o.at));
|
||||
const b = interpolationResults(e.slice(o.at + 1));
|
||||
return { literals: [...a.literals, ...b.literals], complete: a.complete && b.complete };
|
||||
}
|
||||
}
|
||||
return { literals: [], complete: false };
|
||||
}
|
||||
const fallback = ops.filter((o) => o.op === '??' || o.op === '||');
|
||||
if (fallback.length) {
|
||||
const operands = [];
|
||||
let from = 0;
|
||||
for (const o of fallback) {
|
||||
operands.push(e.slice(from, o.at));
|
||||
from = o.at + 2;
|
||||
}
|
||||
operands.push(e.slice(from));
|
||||
// The left operands are tested values; only the literal ones among them could be produced.
|
||||
const literals = operands.flatMap((x) => interpolationResults(x).literals);
|
||||
return { literals, complete: false };
|
||||
}
|
||||
const m = QUOTED_RE.exec(e);
|
||||
return m ? { literals: [m[2]], complete: true } : { literals: [], complete: false };
|
||||
}
|
||||
|
||||
/** How many keys a template with only literal choices may expand to before it is read as a pattern instead. */
|
||||
const MAX_EXPANSION = 64;
|
||||
|
||||
/**
|
||||
* The references one template literal makes. Interpolations that can only
|
||||
* produce string literals (`${open ? 'a' : 'b'}`) are expanded, so each
|
||||
* branch is checked as a literal key and a typo in one fails. When some
|
||||
* interpolation is data, the template stays a pattern, and each literal an
|
||||
* interpolation can still produce (`${role ?? 'editor'}`) is checked with
|
||||
* that interpolation fixed, under the original template's allow-list site.
|
||||
*/
|
||||
function templateRefs(file, line, template) {
|
||||
const parts = template.split(/\$\{([^}]*)\}/);
|
||||
const fixed = parts.filter((_, i) => i % 2 === 0);
|
||||
const results = parts.filter((_, i) => i % 2 === 1).map(interpolationResults);
|
||||
const literal = [];
|
||||
const dynamic = [];
|
||||
const build = (choices) => fixed.map((f, i) => f + (i < choices.length ? choices[i] : '')).join('');
|
||||
const size = results.reduce((n, r) => n * Math.max(r.literals.length, 1), 1);
|
||||
if (results.every((r) => r.complete) && size <= MAX_EXPANSION) {
|
||||
let keys = [''];
|
||||
results.forEach((r, i) => {
|
||||
keys = keys.flatMap((k) => r.literals.map((lit) => k + fixed[i] + lit));
|
||||
});
|
||||
for (const k of keys) literal.push({ file, line, key: k + fixed[fixed.length - 1] });
|
||||
return { literal, dynamic };
|
||||
}
|
||||
dynamic.push({ file, line, template, pattern: templatePattern(template) });
|
||||
const raw = parts.filter((_, i) => i % 2 === 1).map((x) => `\${${x}}`);
|
||||
results.forEach((r, i) => {
|
||||
for (const lit of r.literals) {
|
||||
const choices = raw.map((x, j) => (j === i ? lit : x));
|
||||
const derived = build(choices);
|
||||
if (!derived.includes('${')) literal.push({ file, line, key: derived });
|
||||
else dynamic.push({ file, line, template: derived, pattern: templatePattern(derived), site: template });
|
||||
}
|
||||
});
|
||||
return { literal, dynamic };
|
||||
}
|
||||
|
||||
/** Every key reference in one file's source. */
|
||||
export function scanSource(text, file = '<source>') {
|
||||
const literal = [];
|
||||
const dynamic = [];
|
||||
for (const m of text.matchAll(CALL_RE)) {
|
||||
const open = m.index + m[0].length - 1;
|
||||
const { quoted, templates } = literalsIn(firstArgument(text, open));
|
||||
const line = lineAt(text, m.index);
|
||||
for (const key of quoted) {
|
||||
if (KEY_RE.test(key)) literal.push({ file, line, key });
|
||||
// t('costs.filter.' + f): a prefix with the rest appended, read like a template.
|
||||
else if (PREFIX_RE.test(key))
|
||||
dynamic.push({ file, line, template: `${key}\${…}`, pattern: templatePattern(`${key}\${…}`) });
|
||||
}
|
||||
for (const template of templates) {
|
||||
if (!template.includes('${')) {
|
||||
if (KEY_RE.test(template)) literal.push({ file, line, key: template });
|
||||
continue;
|
||||
}
|
||||
const refs = templateRefs(file, line, template);
|
||||
literal.push(...refs.literal);
|
||||
dynamic.push(...refs.dynamic);
|
||||
}
|
||||
}
|
||||
for (const m of text.matchAll(KEY_BUILDER_RE)) {
|
||||
// Builders of other strings (cache keys) have no fixed dotted prefix and are not translation keys.
|
||||
const pattern = templatePattern(m[1]);
|
||||
if (pattern) dynamic.push({ file, line: lineAt(text, m.index), template: m[1], pattern });
|
||||
}
|
||||
for (const re of [HTML_PROP_RE, API_ERROR_RE, KEY_PROP_RE]) {
|
||||
for (const m of text.matchAll(re)) {
|
||||
if (KEY_RE.test(m[2])) literal.push({ file, line: lineAt(text, m.index), key: m[2] });
|
||||
}
|
||||
}
|
||||
return { literal, dynamic };
|
||||
}
|
||||
|
||||
/** en's keys with the domain file each sits in, read with the shared parity tooling's reader. */
|
||||
export async function readEnKeyFiles() {
|
||||
const { listDomainFiles, readCatalog } = await import(pathToFileURL(join(SHARED_SCRIPTS, 'i18n-catalog.mjs')).href);
|
||||
const files = new Map();
|
||||
for (const file of listDomainFiles('en')) for (const { key } of readCatalog('en', file)) files.set(key, file);
|
||||
if (files.size === 0) throw new Error('shared/src/i18n/en holds no keys: the reader or the path is broken');
|
||||
return files;
|
||||
}
|
||||
|
||||
/** en's keys, read from the locale sources with the shared parity tooling's reader. */
|
||||
export async function readEnKeys() {
|
||||
return new Set((await readEnKeyFiles()).keys());
|
||||
}
|
||||
|
||||
export const hasKey = (enKeys, key) => enKeys.has(key) || enKeys.has(`${key}.other`);
|
||||
|
||||
const base = (k) => k.replace(/\.(?:zero|one|two|few|many|other)$/, '');
|
||||
|
||||
/** How many en keys a pattern reaches, a plural group counted once. */
|
||||
export function countMatches(pattern, enKeys) {
|
||||
const reached = new Set();
|
||||
for (const k of enKeys) {
|
||||
if (pattern.test(k)) reached.add(k);
|
||||
else if (pattern.test(base(k))) reached.add(base(k));
|
||||
}
|
||||
return reached.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* The verdict over a scan: literal keys en lacks, template keys no en key
|
||||
* matches, templates too wide to pass without an allow-list entry,
|
||||
* allow-list entries that no file uses any more or that the check no longer
|
||||
* needs, and the en keys nothing reaches. elsewhere holds the references
|
||||
* outside src/ (scanElsewhere), which only count as reaching keys.
|
||||
*
|
||||
* @param {ReturnType<typeof scanElsewhere>} [elsewhere]
|
||||
*/
|
||||
export function evaluate(scan, enKeys, allowed = DYNAMIC_ALLOWED, elsewhere = { literal: [], dynamic: [] }) {
|
||||
const missing = scan.literal.filter(({ key }) => !hasKey(enKeys, key));
|
||||
const site = (file, template) => `${file}\n${template}`;
|
||||
const allowedSites = new Set(allowed.map((a) => site(a.file, a.template)));
|
||||
const counts = new Map();
|
||||
const reach = (template, pattern) => {
|
||||
if (!counts.has(template)) counts.set(template, pattern ? countMatches(pattern, enKeys) : null);
|
||||
return counts.get(template);
|
||||
};
|
||||
const unmatched = [];
|
||||
const broad = [];
|
||||
for (const d of scan.dynamic) {
|
||||
const n = reach(d.template, d.pattern);
|
||||
const isAllowed = allowedSites.has(site(d.file, d.site ?? d.template));
|
||||
if (n === 0 || (n === null && !isAllowed)) unmatched.push(d);
|
||||
else if (n > MAX_IMPLICIT_MATCHES && !isAllowed) broad.push({ ...d, matches: n });
|
||||
}
|
||||
const usedSites = new Set(scan.dynamic.map((d) => site(d.file, d.site ?? d.template)));
|
||||
const stale = allowed.filter((a) => {
|
||||
if (!usedSites.has(site(a.file, a.template))) return true;
|
||||
if ((a.resolves ?? []).some((k) => !hasKey(enKeys, k))) return true;
|
||||
// An entry for a template narrow enough to pass by itself only lengthens the list.
|
||||
const n = reach(a.template, templatePattern(a.template));
|
||||
return n !== null && n <= MAX_IMPLICIT_MATCHES;
|
||||
});
|
||||
// References outside src/ (see REFERENCE_ROOTS) only mark keys as reached.
|
||||
const reached = new Set([
|
||||
...scan.literal.map((l) => l.key),
|
||||
...elsewhere.literal.map((l) => l.key),
|
||||
...allowed.flatMap((a) => a.resolves ?? []),
|
||||
]);
|
||||
const patterns = [...scan.dynamic, ...elsewhere.dynamic].map((d) => d.pattern).filter(Boolean);
|
||||
const unused = [...enKeys].filter(
|
||||
(k) => !reached.has(k) && !reached.has(base(k)) && !patterns.some((p) => p.test(k) || p.test(base(k)))
|
||||
);
|
||||
return { missing, unmatched, broad, stale, unused };
|
||||
}
|
||||
|
||||
export function scanTree(root = SRC) {
|
||||
if (!existsSync(root)) throw new Error(`${root} does not exist`);
|
||||
const scan = { literal: [], dynamic: [] };
|
||||
for (const path of walk(root)) {
|
||||
const found = scanSource(readFileSync(path, 'utf8'), relative(root, path).split('\\').join('/'));
|
||||
scan.literal.push(...found.literal);
|
||||
scan.dynamic.push(...found.dynamic);
|
||||
}
|
||||
if (scan.literal.length === 0) throw new Error(`no translation key found under ${root}: the scanner is broken`);
|
||||
return scan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every key reference under the reference roots, for the unused count. A root
|
||||
* that is missing or names no key at all is fine here: these are extra places
|
||||
* to look, not the code the check is about.
|
||||
*
|
||||
* @param {string[]} [roots]
|
||||
* @returns {{ literal: { file: string, line: number, key: string }[], dynamic: { file: string, line: number, template: string, pattern: RegExp | null, site?: string }[] }}
|
||||
*/
|
||||
export function scanElsewhere(roots = REFERENCE_ROOTS) {
|
||||
const scan = { literal: [], dynamic: [] };
|
||||
for (const root of roots) {
|
||||
if (!existsSync(root)) continue;
|
||||
for (const path of walk(root)) {
|
||||
const found = scanSource(readFileSync(path, 'utf8'), relative(root, path).split('\\').join('/'));
|
||||
scan.literal.push(...found.literal);
|
||||
scan.dynamic.push(...found.dynamic);
|
||||
}
|
||||
}
|
||||
return scan;
|
||||
}
|
||||
|
||||
/** The unused keys per en domain file. */
|
||||
export function unusedPerFile(unused, keyFiles) {
|
||||
const counts = {};
|
||||
for (const key of unused) {
|
||||
const file = keyFiles.get(key);
|
||||
if (file) counts[file] = (counts[file] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The unused counts held against the baseline: the files that gained unused
|
||||
* keys, the entries above their count (or for a file that is gone), and the
|
||||
* baseline lowered to today, which --update writes. An entry never rises and
|
||||
* a file without one holds no unused key.
|
||||
*/
|
||||
export function compareUnused(baseline, counts) {
|
||||
const grown = Object.entries(counts).filter(([file, n]) => n > (baseline[file] ?? 0));
|
||||
const lowered = {};
|
||||
for (const [file, allowed] of Object.entries(baseline)) {
|
||||
const now = counts[file] ?? 0;
|
||||
if (now > 0) lowered[file] = Math.min(allowed, now);
|
||||
}
|
||||
const stale = Object.entries(baseline)
|
||||
.filter(([file, allowed]) => lowered[file] !== allowed)
|
||||
.map(([file, allowed]) => ({ file, allowed, now: counts[file] ?? 0 }));
|
||||
return { grown, stale, lowered };
|
||||
}
|
||||
|
||||
/** The committed baseline, which fails closed: a missing or malformed file is an error, never empty. */
|
||||
export function readUnusedBaseline(path = UNUSED_BASELINE) {
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(readFileSync(path, 'utf8'));
|
||||
} catch (err) {
|
||||
throw new Error(`scripts/i18n-unused-baseline.json cannot be read (${err.message}); restore it from git`);
|
||||
}
|
||||
const ok =
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.values(value).every((n) => Number.isInteger(n) && n > 0);
|
||||
if (!ok)
|
||||
throw new Error('scripts/i18n-unused-baseline.json is not an object of file → positive count; restore it from git');
|
||||
return value;
|
||||
}
|
||||
|
||||
// Compared by real path, so the check still runs when the script is started through a symlink.
|
||||
const isCli =
|
||||
Boolean(process.argv[1]) &&
|
||||
existsSync(process.argv[1]) &&
|
||||
realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isCli) {
|
||||
try {
|
||||
const scan = scanTree();
|
||||
const keyFiles = await readEnKeyFiles();
|
||||
const enKeys = new Set(keyFiles.keys());
|
||||
const { missing, unmatched, broad, stale, unused } = evaluate(scan, enKeys, DYNAMIC_ALLOWED, scanElsewhere());
|
||||
const unusedCounts = unusedPerFile(unused, keyFiles);
|
||||
let unusedBaseline = readUnusedBaseline();
|
||||
if (process.argv.includes('--update')) {
|
||||
unusedBaseline = compareUnused(unusedBaseline, unusedCounts).lowered;
|
||||
const sorted = Object.fromEntries(Object.entries(unusedBaseline).sort(([a], [b]) => (a < b ? -1 : 1)));
|
||||
writeFileSync(UNUSED_BASELINE, JSON.stringify(sorted, null, 2) + '\n');
|
||||
}
|
||||
const unusedVerdict = compareUnused(unusedBaseline, unusedCounts);
|
||||
for (const { file, line, key } of missing) {
|
||||
console.error(`FAIL ${file}:${line}: '${key}' is not a key in shared/src/i18n/en`);
|
||||
}
|
||||
for (const { file, line, template } of unmatched) {
|
||||
console.error(`FAIL ${file}:${line}: \`${template}\` matches no key in shared/src/i18n/en`);
|
||||
}
|
||||
for (const { file, line, template, matches } of broad) {
|
||||
console.error(
|
||||
`FAIL ${file}:${line}: \`${template}\` reaches ${matches} en keys, more than ${MAX_IMPLICIT_MATCHES}: ` +
|
||||
'narrow the prefix, or add a DYNAMIC_ALLOWED entry saying what bounds the value'
|
||||
);
|
||||
}
|
||||
for (const { file, template } of stale) {
|
||||
console.error(
|
||||
`FAIL DYNAMIC_ALLOWED lists \`${template}\` in ${file}, which that file no longer uses, whose keys en ` +
|
||||
'lacks, or which is narrow enough to pass without it: remove or fix the entry'
|
||||
);
|
||||
}
|
||||
for (const [file, n] of unusedVerdict.grown) {
|
||||
const keys = unused.filter((k) => keyFiles.get(k) === file).slice(0, 5);
|
||||
console.error(
|
||||
`FAIL en/${file}: ${n} key(s) nothing in client, server or plugin-sdk reaches, baseline ` +
|
||||
`${unusedBaseline[file] ?? 0} (${keys.join(', ')}). Use the key, or delete it from every locale.`
|
||||
);
|
||||
}
|
||||
for (const { file, allowed, now } of unusedVerdict.stale) {
|
||||
console.error(
|
||||
`FAIL en/${file} is held at ${allowed} in scripts/i18n-unused-baseline.json, but ${now} key(s) are unused ` +
|
||||
'now. Run npm run lint:i18n-keys -- --update to lower it.'
|
||||
);
|
||||
}
|
||||
if (process.argv.includes('--unused')) for (const key of unused.sort()) console.log(`unused ${key}`);
|
||||
console.log(
|
||||
`i18n keys: ${new Set(scan.literal.map((l) => l.key)).size} literal and ${scan.dynamic.length} template ` +
|
||||
`reference(s) checked against ${enKeys.size} en keys; ${unused.length} en key(s) reached by neither, ` +
|
||||
`baseline allows ${Object.values(unusedBaseline).reduce((a, b) => a + b, 0)} (--unused lists them)`
|
||||
);
|
||||
if (
|
||||
missing.length ||
|
||||
unmatched.length ||
|
||||
broad.length ||
|
||||
stale.length ||
|
||||
unusedVerdict.grown.length ||
|
||||
unusedVerdict.stale.length
|
||||
)
|
||||
process.exit(1);
|
||||
} catch (err) {
|
||||
console.error(`FAIL ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"admin.ts": 152,
|
||||
"atlas.ts": 10,
|
||||
"backup.ts": 10,
|
||||
"budget.ts": 40,
|
||||
"categories.ts": 1,
|
||||
"collab.ts": 9,
|
||||
"collection.ts": 35,
|
||||
"common.ts": 7,
|
||||
"dashboard.ts": 65,
|
||||
"dawarich.ts": 16,
|
||||
"dayplan.ts": 18,
|
||||
"docsync.ts": 25,
|
||||
"files.ts": 4,
|
||||
"help.ts": 3,
|
||||
"inspector.ts": 4,
|
||||
"journey.ts": 70,
|
||||
"login.ts": 1,
|
||||
"map.ts": 5,
|
||||
"memories.ts": 8,
|
||||
"mobileNav.ts": 2,
|
||||
"mobileVacay.ts": 4,
|
||||
"nav.ts": 1,
|
||||
"notif.ts": 7,
|
||||
"notifications.ts": 18,
|
||||
"packing.ts": 6,
|
||||
"pdf.ts": 1,
|
||||
"perm.ts": 4,
|
||||
"places.ts": 10,
|
||||
"planner.ts": 53,
|
||||
"reservations.ts": 21,
|
||||
"roadtrip.ts": 38,
|
||||
"settings.ts": 40,
|
||||
"share.ts": 7,
|
||||
"shared.ts": 2,
|
||||
"storage.ts": 16,
|
||||
"system_notice.ts": 32,
|
||||
"todo.ts": 4,
|
||||
"tours.ts": 14,
|
||||
"trip.ts": 10,
|
||||
"undo.ts": 1,
|
||||
"vacay.ts": 17
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* lint:layers: imports follow the layering, downwards only.
|
||||
*
|
||||
* Components reached up into pages/ for models and helpers, and sideways into
|
||||
* mobile/ for phone hooks and sheets, so nothing could be moved or split
|
||||
* without touching three trees. scripts/lib/layers.mjs holds the rules: a
|
||||
* component imports neither pages/ nor mobile/, a shared hook no view, and
|
||||
* nothing under the views (stores, repos, sync, api, db, helpers) a view at
|
||||
* all. The imports that already broke a rule are counted per file in
|
||||
* scripts/layers-baseline.json, and a file may hold no more of them than its
|
||||
* entry (a file without an entry holds none). Type-only imports count: a type
|
||||
* the lower layer needs belongs below it. An entry above what its file holds
|
||||
* now, or for a file that is gone, fails as well until --update lowers it.
|
||||
*
|
||||
* npm run lint:layers check against the baseline (CI)
|
||||
* npm run lint:layers -- --list print every import against the layering
|
||||
* npm run lint:layers -- --update lower the baseline to what the files hold now;
|
||||
* it never raises an entry
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { check } from './lib/layers.mjs';
|
||||
import { runCli } from './lib/ratchet.mjs';
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
await runCli('lint:layers', (args) =>
|
||||
check({ root, update: args.includes('--update'), list: args.includes('--list') })
|
||||
);
|
||||
@@ -1,62 +0,0 @@
|
||||
{
|
||||
"src/api/client.ts": 1,
|
||||
"src/components/Atlas/AtlasCountryPlaces.tsx": 1,
|
||||
"src/components/Budget/ReceiptScanModal.tsx": 1,
|
||||
"src/components/Collab/CollabLinks.tsx": 1,
|
||||
"src/components/Collab/CollabNotes.tsx": 1,
|
||||
"src/components/Collections/AddPlaceToCollectionModal.tsx": 1,
|
||||
"src/components/Collections/CollectionFilterBar.tsx": 2,
|
||||
"src/components/Collections/CollectionLabelFilter.tsx": 1,
|
||||
"src/components/Collections/CollectionMap.tsx": 1,
|
||||
"src/components/Collections/CollectionPicker.tsx": 1,
|
||||
"src/components/Collections/CollectionPlaceDetail.tsx": 1,
|
||||
"src/components/Collections/ListEditorModal.tsx": 1,
|
||||
"src/components/Collections/MSaveToCollectionSheet.tsx": 3,
|
||||
"src/components/Collections/StatusBadge.tsx": 1,
|
||||
"src/components/Dawarich/DawarichAcceptDialog.tsx": 3,
|
||||
"src/components/Dawarich/DawarichAtlasDialog.tsx": 3,
|
||||
"src/components/Dawarich/DawarichSuggestionsPanel.tsx": 1,
|
||||
"src/components/Journey/ContributorInviteDialog.tsx": 1,
|
||||
"src/components/Journey/JourneyDetailPageAddTripDialog.tsx": 1,
|
||||
"src/components/Journey/JourneyDetailPageChips.tsx": 1,
|
||||
"src/components/Journey/JourneyDetailPageEntryCard.tsx": 2,
|
||||
"src/components/Journey/JourneyDetailPageEntryEditor.tsx": 2,
|
||||
"src/components/Journey/JourneyDetailPageGalleryView.tsx": 1,
|
||||
"src/components/Journey/JourneyDetailPageMapView.tsx": 1,
|
||||
"src/components/Journey/JourneyDetailPagePhotoGrid.tsx": 1,
|
||||
"src/components/Journey/JourneyDetailPageProviderPicker.tsx": 2,
|
||||
"src/components/Journey/JourneyDetailPageSettingsDialog.tsx": 1,
|
||||
"src/components/Journey/JourneyEntryCover.tsx": 2,
|
||||
"src/components/Journey/MobileEntryView.tsx": 1,
|
||||
"src/components/Map/MapView.tsx": 1,
|
||||
"src/components/Map/MapViewGL.tsx": 1,
|
||||
"src/components/Planner/IcsSubscribeModal.tsx": 1,
|
||||
"src/components/Planner/TransitJourneyModal.tsx": 1,
|
||||
"src/components/Planner/TransitSearchPanel.tsx": 1,
|
||||
"src/components/Planner/TripWarningsBanner.tsx": 1,
|
||||
"src/components/Planner/transitDisplay.tsx": 1,
|
||||
"src/components/Plugins/PluginFrame.tsx": 1,
|
||||
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
|
||||
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
|
||||
"src/components/Studio/StudioShell.tsx": 1,
|
||||
"src/components/Studio/StudioSidebar.tsx": 1,
|
||||
"src/components/Studio/TravelElements.tsx": 1,
|
||||
"src/components/Todo/useTodoList.ts": 1,
|
||||
"src/components/Trips/TripMembersModal.tsx": 1,
|
||||
"src/components/shared/ConfirmDialog.tsx": 1,
|
||||
"src/components/shared/CountryFlag.tsx": 1,
|
||||
"src/components/shared/EmptyState.tsx": 1,
|
||||
"src/components/shared/TripLoadingSplash.tsx": 1,
|
||||
"src/components/shared/dialogFocus.ts": 1,
|
||||
"src/hooks/useDawarichConnection.ts": 1,
|
||||
"src/hooks/useDawarichJournalTrail.ts": 1,
|
||||
"src/hooks/useDawarichSuggestions.ts": 1,
|
||||
"src/hooks/useDayNotes.ts": 1,
|
||||
"src/hooks/useRouteCalculation.ts": 4,
|
||||
"src/hooks/useTransportRoutes.ts": 1,
|
||||
"src/hooks/useWebPush.ts": 1,
|
||||
"src/repo/pluginPoiRepo.ts": 1,
|
||||
"src/store/slices/budgetSlice.ts": 1,
|
||||
"src/store/studioStore.ts": 2,
|
||||
"src/store/vacayStore.ts": 1
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
/*
|
||||
* The measuring and the comparison behind lint:dup (scripts/dup-lint.mjs).
|
||||
*
|
||||
* Copies are found by jscpd's detector at the thresholds SonarCloud applies to
|
||||
* TypeScript: a block counts once MIN_TOKENS tokens repeat over at least
|
||||
* MIN_LINES lines. Both .ts and .tsx are read as TypeScript, because Sonar
|
||||
* treats them as one language and a hook in a .ts file that repeats a view's
|
||||
* .tsx code is a copy all the same. A file's count is the number of its lines
|
||||
* inside a copied block, the duplicated lines Sonar reports. Which of three
|
||||
* copies a block is matched against depends on the order the files are read
|
||||
* in, so they are read sorted by path, and with readText, so every machine
|
||||
* counts the same.
|
||||
*/
|
||||
import { Detector, getModeHandler, MemoryStore } from '@jscpd/core';
|
||||
import { Tokenizer } from '@jscpd/tokenizer';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
countMap,
|
||||
listFiles,
|
||||
lowerCounts,
|
||||
readBaseline,
|
||||
readText,
|
||||
reportStale,
|
||||
staleCounts,
|
||||
TEST_FILE,
|
||||
toKey,
|
||||
writeBaseline,
|
||||
} from './ratchet.mjs';
|
||||
|
||||
export const MIN_TOKENS = 100;
|
||||
export const MIN_LINES = 10;
|
||||
|
||||
export const DIRS = ['src'];
|
||||
|
||||
/** The files SonarCloud analyses as source: tests and declaration files are left out. */
|
||||
export function accepts(key) {
|
||||
return /\.tsx?$/.test(key) && !key.endsWith('.d.ts') && !TEST_FILE.test(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every copied block under root as a pair of line ranges, one per side. Both
|
||||
* sides can be the same file.
|
||||
*
|
||||
* @returns {Promise<{ key: string, start: number, end: number }[][]>}
|
||||
*/
|
||||
export async function findCopies(root) {
|
||||
const keys = listFiles(root, DIRS, accepts)
|
||||
.map((path) => toKey(root, path))
|
||||
.sort();
|
||||
const detector = new Detector(new Tokenizer(), new MemoryStore(), [], {
|
||||
minTokens: MIN_TOKENS,
|
||||
minLines: MIN_LINES,
|
||||
mode: getModeHandler('mild'),
|
||||
});
|
||||
const side = ({ sourceId, start, end }) => ({ key: sourceId, start: start.line, end: end.line });
|
||||
const copies = [];
|
||||
for (const key of keys) {
|
||||
const clones = await detector.detect(key, readText(join(root, key)), 'typescript');
|
||||
for (const clone of clones) copies.push([side(clone.duplicationA), side(clone.duplicationB)]);
|
||||
}
|
||||
return copies;
|
||||
}
|
||||
|
||||
/** Per file in path order, how many of its lines sit inside a copied block. */
|
||||
export function countLines(copies) {
|
||||
const lines = {};
|
||||
for (const pair of copies) {
|
||||
for (const { key, start, end } of pair) {
|
||||
const set = (lines[key] ??= new Set());
|
||||
for (let line = start; line <= end; line++) set.add(line);
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(lines)
|
||||
.filter(([, set]) => set.size > 0)
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([key, set]) => [key, set.size])
|
||||
);
|
||||
}
|
||||
|
||||
/** Per file, its copied blocks as "start-end = other:start-end", in line order. */
|
||||
export function describeCopies(copies) {
|
||||
const found = {};
|
||||
const range = ({ start, end }) => `${start}-${end}`;
|
||||
for (const [a, b] of copies) {
|
||||
(found[a.key] ??= []).push({ at: a.start, text: `${range(a)} = ${b.key}:${range(b)}` });
|
||||
(found[b.key] ??= []).push({ at: b.start, text: `${range(b)} = ${a.key}:${range(a)}` });
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(found).map(([key, list]) => [key, list.sort((x, y) => x.at - y.at).map(({ text }) => text)])
|
||||
);
|
||||
}
|
||||
|
||||
/** Runs the check against the baseline at baselinePath and returns the exit code. */
|
||||
export async function check({
|
||||
root,
|
||||
baselinePath = join(root, 'scripts/dup-baseline.json'),
|
||||
update = false,
|
||||
list = false,
|
||||
log = console.log,
|
||||
error = console.error,
|
||||
}) {
|
||||
const copies = await findCopies(root);
|
||||
const counts = countLines(copies);
|
||||
const blocks = describeCopies(copies);
|
||||
let baseline = readBaseline(baselinePath, countMap);
|
||||
|
||||
if (list) {
|
||||
for (const [key, n] of Object.entries(counts)) {
|
||||
log(`${key} (${n}, baseline ${baseline[key] ?? 0})`);
|
||||
for (const line of blocks[key]) log(` ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (update) {
|
||||
baseline = lowerCounts(baseline, counts);
|
||||
writeBaseline(baselinePath, baseline);
|
||||
}
|
||||
|
||||
const grown = Object.entries(counts).filter(([key, n]) => n > (baseline[key] ?? 0));
|
||||
const stale = staleCounts(baseline, counts);
|
||||
|
||||
for (const [key, n] of grown) {
|
||||
error(`FAIL ${key}: ${n} line(s) in copied blocks, baseline ${baseline[key] ?? 0}:`);
|
||||
for (const line of blocks[key]) error(` ${line}`);
|
||||
}
|
||||
if (grown.length) {
|
||||
error(
|
||||
'Put the code both places need in one shared hook or module and import it from both instead of copying it ' +
|
||||
'(useInstanceSettings and useRangeBypass under src/components/Admin/ are the pattern).'
|
||||
);
|
||||
}
|
||||
reportStale(stale, { file: 'dup-baseline.json', command: 'lint:dup', root, error });
|
||||
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
|
||||
log(
|
||||
`dup: ${sum(counts)} line(s) in copied blocks in ${Object.keys(counts).length} file(s), baseline allows ${sum(baseline)}`
|
||||
);
|
||||
return grown.length || stale.length ? 1 : 0;
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
/*
|
||||
* The client's own ESLint rules, as the local plugin `trek` in eslint.config.mjs.
|
||||
*
|
||||
* Each one finds a smell client/CLAUDE.md forbids and that no stock rule sees
|
||||
* on its own. They run as warnings, so lint:warnings
|
||||
* (scripts/eslint-ratchet.mjs) holds what exists today in
|
||||
* scripts/eslint-baseline.json and fails on each new one.
|
||||
*/
|
||||
|
||||
/** The expression under any `as`, `!`, `satisfies` or type assertion, which do not change the value. */
|
||||
function unwrap(node) {
|
||||
let current = node;
|
||||
while (
|
||||
current &&
|
||||
(current.type === 'TSAsExpression' ||
|
||||
current.type === 'TSNonNullExpression' ||
|
||||
current.type === 'TSSatisfiesExpression' ||
|
||||
current.type === 'TSTypeAssertion')
|
||||
)
|
||||
current = current.expression;
|
||||
return current;
|
||||
}
|
||||
|
||||
const isWindow = (node) => {
|
||||
const target = unwrap(node);
|
||||
return (
|
||||
(target?.type === 'Identifier' && (target.name === 'window' || target.name === 'globalThis')) ||
|
||||
(target?.type === 'MemberExpression' &&
|
||||
!target.computed &&
|
||||
target.property.name === 'window' &&
|
||||
unwrap(target.object)?.type === 'Identifier' &&
|
||||
unwrap(target.object).name === 'globalThis')
|
||||
);
|
||||
};
|
||||
|
||||
const propertyName = (member) =>
|
||||
!member.computed && member.property.type === 'Identifier'
|
||||
? member.property.name
|
||||
: member.property.type === 'Literal' && typeof member.property.value === 'string'
|
||||
? member.property.value
|
||||
: null;
|
||||
|
||||
/**
|
||||
* `.catch(() => {})`, `.catch(() => undefined)`, `.catch(() => null)`: the
|
||||
* failure disappears without a trace. A write that fails needs a rollback and
|
||||
* a toast; a read that may fail says why it may, in code that handles it.
|
||||
*/
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const noSwallowedCatch = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
schema: [],
|
||||
messages: {
|
||||
swallowed:
|
||||
'This .catch() swallows the error. Roll back and tell the user (toast), or handle the failure on purpose ' +
|
||||
'and say why in the handler; never .catch(() => {}).',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const empty = (fn) => {
|
||||
if (fn?.type !== 'ArrowFunctionExpression' && fn?.type !== 'FunctionExpression') return false;
|
||||
const body = fn.body;
|
||||
if (body.type === 'BlockStatement') return body.body.length === 0;
|
||||
return (
|
||||
(body.type === 'Identifier' && body.name === 'undefined') ||
|
||||
(body.type === 'Literal' && body.value === null) ||
|
||||
(body.type === 'UnaryExpression' && body.operator === 'void')
|
||||
);
|
||||
};
|
||||
return {
|
||||
CallExpression(node) {
|
||||
const callee = node.callee;
|
||||
if (callee.type !== 'MemberExpression' || propertyName(callee) !== 'catch') return;
|
||||
if (node.arguments.length === 1 && empty(node.arguments[0])) context.report({ node, messageId: 'swallowed' });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `useTripStore()` with no selector subscribes the component to the whole
|
||||
* store, so it re-renders on every change anywhere in it. And on Zustand 5 the
|
||||
* obvious repair, a selector that builds an object, loops forever without
|
||||
* useShallow, because each call returns a new object.
|
||||
*/
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const storeWithoutSelector = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
schema: [],
|
||||
messages: {
|
||||
whole:
|
||||
'{{name}}() without a selector subscribes to the whole store and re-renders on every change in it. ' +
|
||||
'Select what you read: {{name}}(s => s.field), or for several fields {{name}}(useShallow(s => ({ a: s.a, b: s.b }))). ' +
|
||||
'On Zustand 5 an object selector without useShallow loops forever. In an effect or handler use {{name}}.getState().',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (
|
||||
node.callee.type === 'Identifier' &&
|
||||
/^use[A-Z]\w*Store$/.test(node.callee.name) &&
|
||||
node.arguments.length === 0
|
||||
)
|
||||
context.report({ node, messageId: 'whole', data: { name: node.callee.name } });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Events the browser itself fires on window; dispatching one of these is simulating the platform, not a bus. */
|
||||
const PLATFORM_EVENTS = new Set([
|
||||
'resize',
|
||||
'scroll',
|
||||
'storage',
|
||||
'online',
|
||||
'offline',
|
||||
'focus',
|
||||
'blur',
|
||||
'popstate',
|
||||
'hashchange',
|
||||
'visibilitychange',
|
||||
'load',
|
||||
'beforeunload',
|
||||
'pagehide',
|
||||
'pageshow',
|
||||
'message',
|
||||
'keydown',
|
||||
'keyup',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Global mutable state and event buses on window: `window.__dragData`,
|
||||
* `window.__addToast` and any other `window.__*`, and window.dispatchEvent
|
||||
* with an event of the app's own. Nothing types them, nothing finds their
|
||||
* listeners, and a test has to fake the global to reach them.
|
||||
*/
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const noWindowGlobals = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
schema: [],
|
||||
messages: {
|
||||
global:
|
||||
'window.{{name}} is global mutable state. Keep it in a module (a store, a small session module) and import it.',
|
||||
bus:
|
||||
'window.dispatchEvent with an event of our own is an untyped global bus. Call a store action, or bump a ' +
|
||||
'version counter in a slice that the listener selects.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const ownEvent = (arg) => {
|
||||
const event = unwrap(arg);
|
||||
if (event?.type !== 'NewExpression' || event.callee.type !== 'Identifier') return false;
|
||||
if (event.callee.name === 'CustomEvent') return true;
|
||||
if (event.callee.name !== 'Event') return false;
|
||||
const name = event.arguments[0];
|
||||
return !(name?.type === 'Literal' && typeof name.value === 'string' && PLATFORM_EVENTS.has(name.value));
|
||||
};
|
||||
return {
|
||||
MemberExpression(node) {
|
||||
const name = propertyName(node);
|
||||
if (name?.startsWith('__') && isWindow(node.object))
|
||||
context.report({ node, messageId: 'global', data: { name } });
|
||||
},
|
||||
CallExpression(node) {
|
||||
const callee = unwrap(node.callee);
|
||||
if (callee?.type !== 'MemberExpression' || propertyName(callee) !== 'dispatchEvent') return;
|
||||
if (isWindow(callee.object) && ownEvent(node.arguments[0])) context.report({ node, messageId: 'bus' });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
meta: { name: 'trek' },
|
||||
rules: {
|
||||
'no-swallowed-catch': noSwallowedCatch,
|
||||
'store-without-selector': storeWithoutSelector,
|
||||
'no-window-globals': noWindowGlobals,
|
||||
},
|
||||
};
|
||||
@@ -1,195 +0,0 @@
|
||||
/*
|
||||
* The tally and the comparison behind lint:warnings (scripts/eslint-ratchet.mjs).
|
||||
*/
|
||||
import { ESLint } from 'eslint';
|
||||
import { join } from 'node:path';
|
||||
import ts from 'typescript';
|
||||
import {
|
||||
countMap,
|
||||
lowerCounts,
|
||||
readBaseline,
|
||||
readText,
|
||||
reportStale,
|
||||
staleCounts,
|
||||
TEST_FILE,
|
||||
toKey,
|
||||
writeBaseline,
|
||||
} from './ratchet.mjs';
|
||||
|
||||
/**
|
||||
* Where a warning is counted: in the app code, in the tests, or, for a
|
||||
* message an eslint-disable comment silenced, among the suppressed ones. An
|
||||
* inline disable must not be the cheap way under the ratchet, and an inline
|
||||
* rule config, which silences without a trace, fails outright (see tally).
|
||||
*/
|
||||
export const AREAS = ['src', 'tests', 'suppressed'];
|
||||
|
||||
const areaOf = (key) => (key.startsWith('tests/') || TEST_FILE.test(key) ? 'tests' : 'src');
|
||||
|
||||
const ruleOf = (message) =>
|
||||
message.ruleId ?? (/eslint-disable/.test(message.message) ? 'unused-disable-directive' : '(no rule)');
|
||||
|
||||
/** A baseline: each area an object of rule → positive count. */
|
||||
export function areaCounts(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return 'it is not an object of area → rule counts';
|
||||
for (const area of Object.keys(value))
|
||||
if (!AREAS.includes(area)) return `"${area}" is not one of ${AREAS.join(', ')}`;
|
||||
for (const area of AREAS) {
|
||||
if (!(area in value)) return `the "${area}" area is missing`;
|
||||
const problem = countMap(value[area]);
|
||||
if (problem) return `in "${area}", ${problem}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const scriptKind = (file) =>
|
||||
file.endsWith('.tsx')
|
||||
? ts.ScriptKind.TSX
|
||||
: /\.[cm]?ts$/.test(file)
|
||||
? ts.ScriptKind.TS
|
||||
: file.endsWith('.jsx')
|
||||
? ts.ScriptKind.JSX
|
||||
: ts.ScriptKind.JS;
|
||||
|
||||
/** ESLint's label for a block comment that configures rules, as opposed to eslint-disable and the like. */
|
||||
const INLINE_CONFIG = /^\s*eslint(?:\s|$)/;
|
||||
|
||||
/**
|
||||
* The block comments in a file that configure ESLint rules, such as
|
||||
* `/* eslint no-empty: off *\/`, as `line:column comment`. Such a comment
|
||||
* switches a rule off for the whole file without a single message reaching
|
||||
* either the warnings or the suppressed ones, so it would walk past the
|
||||
* ratchet unseen. The comments come from the TypeScript parser, so the same
|
||||
* text inside a string or a template does not count.
|
||||
*/
|
||||
export function inlineConfigsOf(source, file) {
|
||||
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, scriptKind(file));
|
||||
const seen = new Set();
|
||||
const found = [];
|
||||
const collect = (ranges) => {
|
||||
for (const range of ranges ?? []) {
|
||||
if (seen.has(range.pos)) continue;
|
||||
seen.add(range.pos);
|
||||
if (range.kind !== ts.SyntaxKind.MultiLineCommentTrivia) continue;
|
||||
const text = source.slice(range.pos, range.end);
|
||||
if (!INLINE_CONFIG.test(text.slice(2, -2))) continue;
|
||||
const { line, character } = sf.getLineAndCharacterOfPosition(range.pos);
|
||||
found.push(`${line + 1}:${character + 1} ${text.replace(/\s+/g, ' ').slice(0, 80)}`);
|
||||
}
|
||||
};
|
||||
const visit = (node) => {
|
||||
collect(ts.getLeadingCommentRanges(source, node.pos));
|
||||
collect(ts.getTrailingCommentRanges(source, node.pos));
|
||||
for (const child of node.getChildren(sf)) visit(child);
|
||||
};
|
||||
visit(sf);
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The warnings per area and rule in ESLint's results, the files behind each
|
||||
* count, and every error (an error, a parse failure included, always fails).
|
||||
* An inline rule config comment (see inlineConfigsOf) is an error too: the
|
||||
* place to change a rule is eslint.config.mjs.
|
||||
*/
|
||||
export function tally(results, root) {
|
||||
const counts = Object.fromEntries(AREAS.map((area) => [area, {}]));
|
||||
const files = Object.fromEntries(AREAS.map((area) => [area, {}]));
|
||||
const errors = [];
|
||||
const add = (area, rule, key) => {
|
||||
counts[area][rule] = (counts[area][rule] ?? 0) + 1;
|
||||
const where = (files[area][rule] ??= {});
|
||||
where[key] = (where[key] ?? 0) + 1;
|
||||
};
|
||||
for (const result of results) {
|
||||
const key = toKey(root, result.filePath);
|
||||
for (const comment of result.inlineConfigs ?? [])
|
||||
errors.push(`${key}:${comment} configures a rule inline; set it in eslint.config.mjs (inline-config)`);
|
||||
for (const message of result.messages) {
|
||||
if (message.fatal || message.severity === 2)
|
||||
errors.push(`${key}:${message.line ?? 0}:${message.column ?? 0} ${message.message} (${ruleOf(message)})`);
|
||||
else add(areaOf(key), ruleOf(message), key);
|
||||
}
|
||||
for (const message of result.suppressedMessages ?? []) add('suppressed', ruleOf(message), key);
|
||||
}
|
||||
return { counts, files, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of an ESLint result the tally reads.
|
||||
*
|
||||
* @typedef {{ ruleId: string | null, severity: number, message: string, fatal?: boolean, line?: number, column?: number }} LintMessage
|
||||
* @typedef {{ filePath: string, messages: LintMessage[], suppressedMessages?: LintMessage[], inlineConfigs?: string[] }} LintResult
|
||||
*/
|
||||
|
||||
/**
|
||||
* ESLint over the whole client, with its own config, as `npm run lint:check`
|
||||
* runs it, each result with the inline rule configs of its file.
|
||||
*
|
||||
* @param {string} root
|
||||
* @returns {Promise<LintResult[]>}
|
||||
*/
|
||||
export async function lintClient(root) {
|
||||
const results = await new ESLint({ cwd: root }).lintFiles(['.']);
|
||||
return results.map((result) => ({
|
||||
...result,
|
||||
inlineConfigs: inlineConfigsOf(readText(result.filePath), result.filePath),
|
||||
}));
|
||||
}
|
||||
|
||||
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
|
||||
|
||||
/**
|
||||
* Runs ESLint (or lint, in a test) and compares the warnings with the
|
||||
* baseline at baselinePath. Returns the exit code.
|
||||
*/
|
||||
export async function check({
|
||||
root,
|
||||
baselinePath = join(root, 'scripts/eslint-baseline.json'),
|
||||
update = false,
|
||||
lint = lintClient,
|
||||
log = console.log,
|
||||
error = console.error,
|
||||
}) {
|
||||
let baseline = readBaseline(baselinePath, areaCounts);
|
||||
const { counts, files, errors } = tally(await lint(root), root);
|
||||
|
||||
if (update) {
|
||||
baseline = Object.fromEntries(AREAS.map((area) => [area, lowerCounts(baseline[area], counts[area])]));
|
||||
writeBaseline(baselinePath, baseline);
|
||||
}
|
||||
|
||||
for (const line of errors) error(`FAIL ${line}`);
|
||||
let grown = 0;
|
||||
let stale = 0;
|
||||
for (const area of AREAS) {
|
||||
for (const [rule, n] of Object.entries(counts[area])) {
|
||||
const allowed = baseline[area][rule] ?? 0;
|
||||
if (n <= allowed) continue;
|
||||
grown++;
|
||||
const top = Object.entries(files[area][rule])
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
.map(([key, k]) => `${key} (${k})`);
|
||||
error(`FAIL ${area}: ${n} ${rule} warning(s), baseline ${allowed}. Most in: ${top.join(', ')}`);
|
||||
}
|
||||
const fallen = staleCounts(baseline[area], counts[area]);
|
||||
reportStale(fallen, {
|
||||
file: 'eslint-baseline.json',
|
||||
command: 'lint:warnings',
|
||||
label: (rule) => `${area}: ${rule}`,
|
||||
error,
|
||||
});
|
||||
stale += fallen.length;
|
||||
}
|
||||
if (grown) {
|
||||
error(
|
||||
'Fix the new warning rather than silencing it: an eslint-disable comment is counted under "suppressed". ' +
|
||||
'Run npx eslint <file> to see each one.'
|
||||
);
|
||||
}
|
||||
log(
|
||||
`eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)`
|
||||
);
|
||||
return errors.length || grown || stale ? 1 : 0;
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
* The comparison behind lint:format (scripts/format-lint.mjs): which files
|
||||
* Prettier would rewrite, against the list of files that were unformatted
|
||||
* when the check came in.
|
||||
*/
|
||||
import { join } from 'node:path';
|
||||
import * as prettier from 'prettier';
|
||||
import {
|
||||
fileList,
|
||||
listFiles,
|
||||
lowerList,
|
||||
RatchetError,
|
||||
readBaseline,
|
||||
readText,
|
||||
toKey,
|
||||
writeBaseline,
|
||||
} from './ratchet.mjs';
|
||||
|
||||
/** The app, its tests, the Playwright specs and the client's own tooling. */
|
||||
export const DIRS = ['src', 'tests', 'e2e', 'scripts'];
|
||||
|
||||
export const accepts = (key) => /\.(?:tsx?|mjs|css)$/.test(key);
|
||||
|
||||
/**
|
||||
* Whether Prettier leaves the file as it is, with the config that applies to
|
||||
* it. The text is read with LF line ends, so a Windows checkout agrees with CI
|
||||
* about endOfLine. A file Prettier cannot parse is not formatted.
|
||||
*/
|
||||
export async function isFormatted(path) {
|
||||
const options = await prettier.resolveConfig(path);
|
||||
if (!options) throw new RatchetError(`no Prettier config applies to ${path}`);
|
||||
try {
|
||||
return await prettier.check(readText(path), { ...options, filepath: path });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The files under root that Prettier would rewrite, keyed by their path from root. */
|
||||
export async function unformatted(root) {
|
||||
const found = [];
|
||||
for (const path of listFiles(root, DIRS, accepts)) if (!(await isFormatted(path))) found.push(toKey(root, path));
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the check against the list at baselinePath. A file outside the list
|
||||
* must be formatted. A listed file that is formatted now, or gone, fails the
|
||||
* check as well until --update takes it out: the list only shrinks, and a
|
||||
* stale entry would let the next file at that path in unformatted. Returns
|
||||
* the exit code.
|
||||
*/
|
||||
export async function check({
|
||||
root,
|
||||
baselinePath = join(root, 'scripts/format-baseline.json'),
|
||||
update = false,
|
||||
list = false,
|
||||
log = console.log,
|
||||
error = console.error,
|
||||
}) {
|
||||
const found = await unformatted(root);
|
||||
let baseline = readBaseline(baselinePath, fileList);
|
||||
|
||||
if (update) {
|
||||
baseline = lowerList(baseline, found);
|
||||
writeBaseline(baselinePath, baseline);
|
||||
}
|
||||
|
||||
const allowed = new Set(baseline);
|
||||
const fresh = found.filter((key) => !allowed.has(key));
|
||||
const now = new Set(found);
|
||||
const stale = baseline.filter((key) => !now.has(key));
|
||||
|
||||
if (list) for (const key of found) log(`${allowed.has(key) ? 'listed' : 'new '} ${key}`);
|
||||
for (const key of fresh) {
|
||||
error(`FAIL ${key} is not formatted. Run: npx prettier --write ${key}`);
|
||||
}
|
||||
for (const key of stale) {
|
||||
error(`FAIL ${key} is listed in scripts/format-baseline.json but is formatted now or gone.`);
|
||||
}
|
||||
if (stale.length) error('Run npm run lint:format -- --update to take the formatted files off the list.');
|
||||
log(`format: ${found.length} unformatted file(s), ${baseline.length} listed in the baseline`);
|
||||
return fresh.length || stale.length ? 1 : 0;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/*
|
||||
* The imports of a source file, read from its syntax tree rather than with a
|
||||
* regular expression, so an import split over lines, a dynamic import() and a
|
||||
* re-export count, and an import inside a comment or a string does not.
|
||||
*/
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import ts from 'typescript';
|
||||
import { listFiles, readText, TEST_FILE, toKey } from './ratchet.mjs';
|
||||
|
||||
const bindingsAreTypes = (elements) => elements.length > 0 && elements.every((el) => el.isTypeOnly);
|
||||
|
||||
/**
|
||||
* Every module the file names, with the line it is named on and whether the
|
||||
* import only brings in types (erased from the bundle, so no runtime path).
|
||||
*/
|
||||
export function importsOf(source, file) {
|
||||
const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
||||
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind);
|
||||
const found = [];
|
||||
const add = (node, literal, typeOnly) => {
|
||||
if (!literal || !ts.isStringLiteralLike(literal)) return;
|
||||
found.push({
|
||||
specifier: literal.text,
|
||||
line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1,
|
||||
typeOnly,
|
||||
});
|
||||
};
|
||||
const visit = (node) => {
|
||||
if (ts.isImportDeclaration(node)) {
|
||||
const clause = node.importClause;
|
||||
const named =
|
||||
clause?.namedBindings && ts.isNamedImports(clause.namedBindings) ? clause.namedBindings.elements : null;
|
||||
const typeOnly = Boolean(clause && (clause.isTypeOnly || (!clause.name && named && bindingsAreTypes(named))));
|
||||
add(node, node.moduleSpecifier, typeOnly);
|
||||
} else if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
|
||||
const named = node.exportClause && ts.isNamedExports(node.exportClause) ? node.exportClause.elements : null;
|
||||
add(node, node.moduleSpecifier, node.isTypeOnly || Boolean(named && bindingsAreTypes(named)));
|
||||
} else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
|
||||
add(node, node.moduleReference.expression, node.isTypeOnly);
|
||||
} else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
||||
add(node, node.argument.literal, true);
|
||||
} else if (
|
||||
ts.isCallExpression(node) &&
|
||||
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
|
||||
(ts.isIdentifier(node.expression) && node.expression.text === 'require'))
|
||||
) {
|
||||
add(node, node.arguments[0], false);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sf);
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The layer of a path from src/: its top directory ("components", "api"), or,
|
||||
* for a file directly in src/, its name without the extension. src/types.ts
|
||||
* is then the layer "types" both when it imports and when it is imported as
|
||||
* '../types', and its rules are those of the src/types/ directory.
|
||||
*/
|
||||
export function layerOfPath(fromSrc) {
|
||||
const [top, ...rest] = fromSrc.split('/');
|
||||
return rest.length ? top : top.replace(/(\.d)?\.[cm]?[jt]sx?$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The layer a relative import lands in (see layerOfPath), or null for a
|
||||
* package or a path outside src/.
|
||||
*/
|
||||
export function layerOf(srcRoot, fromFile, specifier) {
|
||||
if (!specifier.startsWith('.')) return null;
|
||||
const target = relative(srcRoot, resolve(dirname(fromFile), specifier))
|
||||
.split('\\')
|
||||
.join('/');
|
||||
if (!target || target.startsWith('..')) return null;
|
||||
return layerOfPath(target);
|
||||
}
|
||||
|
||||
/** Every source file under root/src outside the tests, with its key, its layer and its imports resolved to layers. */
|
||||
export function sourceGraph(root) {
|
||||
const src = join(root, 'src');
|
||||
return listFiles(root, ['src'], (key) => /\.tsx?$/.test(key) && !TEST_FILE.test(key)).map((path) => ({
|
||||
key: toKey(root, path),
|
||||
layer: layerOfPath(toKey(src, path)),
|
||||
imports: importsOf(readText(path), path).map((imp) => ({ ...imp, layer: layerOf(src, path, imp.specifier) })),
|
||||
}));
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
/*
|
||||
* The rules and the comparison behind lint:layers (scripts/layer-lint.mjs).
|
||||
*/
|
||||
import { join } from 'node:path';
|
||||
import { sourceGraph } from './imports.mjs';
|
||||
import { countMap, lowerCounts, readBaseline, reportStale, staleCounts, writeBaseline } from './ratchet.mjs';
|
||||
|
||||
const VIEW = ['components', 'mobile', 'pages', 'hooks'];
|
||||
|
||||
/**
|
||||
* For each directory under src/, the directories its files may not import
|
||||
* from. The view layers sit on top: a page composes components, a component
|
||||
* may use a shared hook, and the phone screens reuse both. Nothing below the
|
||||
* views (stores, repos, sync, api, the Dexie schema and the helpers) knows a
|
||||
* view exists, a repo sits under the stores, and the Dexie schema under all
|
||||
* of them. help/ (the help center's screen registry, plain data) and vacay/
|
||||
* (date helpers) are helpers like utils/. Directories without an entry are
|
||||
* free on purpose: mobile and pages sit at the top, the app root wires
|
||||
* everything, and managed/ is the slot an operator fills with whole screens
|
||||
* at build time. A file directly in src/ is the layer of its name, so
|
||||
* src/types.ts falls under types like the src/types/ directory.
|
||||
*/
|
||||
export const RULES = {
|
||||
components: ['pages', 'mobile'],
|
||||
hooks: ['pages', 'mobile', 'components'],
|
||||
store: VIEW,
|
||||
sync: VIEW,
|
||||
api: VIEW,
|
||||
utils: VIEW,
|
||||
i18n: VIEW,
|
||||
theme: VIEW,
|
||||
constants: VIEW,
|
||||
services: VIEW,
|
||||
push: VIEW,
|
||||
types: VIEW,
|
||||
help: VIEW,
|
||||
vacay: VIEW,
|
||||
repo: [...VIEW, 'store'],
|
||||
db: [...VIEW, 'store', 'repo', 'sync', 'api'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Every import that crosses a boundary the wrong way, per file. A type-only
|
||||
* import counts too: a type the lower layer needs belongs below it.
|
||||
*/
|
||||
export function violations(root, rules = RULES) {
|
||||
const found = {};
|
||||
for (const file of sourceGraph(root)) {
|
||||
const banned = rules[file.layer];
|
||||
if (!banned) continue;
|
||||
const wrong = file.imports.filter((imp) => imp.layer && banned.includes(imp.layer));
|
||||
if (wrong.length)
|
||||
found[file.key] = wrong.map((imp) => `${imp.line}: '${imp.specifier}' (${file.layer} into ${imp.layer})`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Runs the check against the baseline at baselinePath and returns the exit code. */
|
||||
export function check({
|
||||
root,
|
||||
baselinePath = join(root, 'scripts/layers-baseline.json'),
|
||||
rules = RULES,
|
||||
update = false,
|
||||
list = false,
|
||||
log = console.log,
|
||||
error = console.error,
|
||||
}) {
|
||||
const found = violations(root, rules);
|
||||
const counts = Object.fromEntries(Object.entries(found).map(([key, list]) => [key, list.length]));
|
||||
let baseline = readBaseline(baselinePath, countMap);
|
||||
|
||||
if (list) {
|
||||
for (const [key, wrong] of Object.entries(found)) {
|
||||
log(`${key} (${wrong.length}, baseline ${baseline[key] ?? 0})`);
|
||||
for (const line of wrong) log(` ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (update) {
|
||||
baseline = lowerCounts(baseline, counts);
|
||||
writeBaseline(baselinePath, baseline);
|
||||
}
|
||||
|
||||
const grown = Object.entries(counts).filter(([key, n]) => n > (baseline[key] ?? 0));
|
||||
const stale = staleCounts(baseline, counts);
|
||||
|
||||
for (const [key, n] of grown) {
|
||||
error(`FAIL ${key}: ${n} import(s) against the layering, baseline ${baseline[key] ?? 0}:`);
|
||||
for (const line of found[key]) error(` ${line}`);
|
||||
}
|
||||
if (grown.length) {
|
||||
error(
|
||||
'Move what both sides need down into a layer they may share (a model or helper under utils/, ' +
|
||||
'a hook under hooks/, a type under types/) instead of importing upwards.'
|
||||
);
|
||||
}
|
||||
reportStale(stale, { file: 'layers-baseline.json', command: 'lint:layers', root, error });
|
||||
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
|
||||
log(
|
||||
`layers: ${sum(counts)} import(s) against the layering in ${Object.keys(counts).length} file(s), baseline allows ${sum(baseline)}`
|
||||
);
|
||||
return grown.length || stale.length ? 1 : 0;
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
* The comparison behind lint:offline (scripts/offline-lint.mjs).
|
||||
*/
|
||||
import { join } from 'node:path';
|
||||
import { sourceGraph } from './imports.mjs';
|
||||
import { fileList, lowerList, readBaseline, writeBaseline } from './ratchet.mjs';
|
||||
|
||||
/** The layers that render or drive a view, and so must not hold a network call. */
|
||||
export const VIEW_LAYERS = ['components', 'mobile', 'pages', 'hooks'];
|
||||
|
||||
/**
|
||||
* The view files that import from src/api/ for anything but types, each with
|
||||
* the imports that do it. A type-only import is erased from the bundle and
|
||||
* carries no request, so it does not count.
|
||||
*/
|
||||
export function bypasses(root) {
|
||||
const found = {};
|
||||
for (const file of sourceGraph(root)) {
|
||||
if (!VIEW_LAYERS.includes(file.layer)) continue;
|
||||
const direct = file.imports.filter((imp) => imp.layer === 'api' && !imp.typeOnly);
|
||||
if (direct.length) found[file.key] = direct.map((imp) => `${imp.line}: '${imp.specifier}'`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the check against the list at baselinePath. A view file outside the
|
||||
* list may not import src/api/. A listed file that no longer does fails the
|
||||
* check as well until --update takes it out, so the list only shrinks and
|
||||
* the next file at that path does not inherit the exemption. Returns the exit
|
||||
* code.
|
||||
*/
|
||||
export function check({
|
||||
root,
|
||||
baselinePath = join(root, 'scripts/offline-baseline.json'),
|
||||
update = false,
|
||||
list = false,
|
||||
log = console.log,
|
||||
error = console.error,
|
||||
}) {
|
||||
const found = bypasses(root);
|
||||
const offenders = Object.keys(found);
|
||||
let baseline = readBaseline(baselinePath, fileList);
|
||||
|
||||
if (update) {
|
||||
baseline = lowerList(baseline, offenders);
|
||||
writeBaseline(baselinePath, baseline);
|
||||
}
|
||||
|
||||
const allowed = new Set(baseline);
|
||||
const fresh = offenders.filter((key) => !allowed.has(key));
|
||||
const stale = baseline.filter((key) => !found[key]);
|
||||
|
||||
if (list) {
|
||||
for (const [key, direct] of Object.entries(found)) {
|
||||
log(`${allowed.has(key) ? 'listed' : 'new '} ${key}`);
|
||||
for (const line of direct) log(` ${line}`);
|
||||
}
|
||||
}
|
||||
for (const key of fresh) {
|
||||
error(`FAIL ${key} imports src/api/ directly:`);
|
||||
for (const line of found[key]) error(` ${line}`);
|
||||
}
|
||||
if (fresh.length) {
|
||||
error(
|
||||
'A view reads through a repo (src/repo/) or a store and writes through the mutation queue, ' +
|
||||
'so it keeps working offline. A domain that is online-only on purpose still gets its calls ' +
|
||||
'in a store or a repo, not in the view. See client/CLAUDE.md, "Data flow".'
|
||||
);
|
||||
}
|
||||
for (const key of stale) {
|
||||
error(`FAIL ${key} is listed in scripts/offline-baseline.json but no longer imports src/api/ (or is gone).`);
|
||||
}
|
||||
if (stale.length) error('Run npm run lint:offline -- --update to take it off the list.');
|
||||
log(`offline: ${offenders.length} view file(s) import src/api/ directly, ${baseline.length} listed in the baseline`);
|
||||
return fresh.length || stale.length ? 1 : 0;
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
/*
|
||||
* The manifest and the checks behind lint:pairs (scripts/pairs-lint.mjs).
|
||||
*/
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { importsOf } from './imports.mjs';
|
||||
import { listFiles, readBaseline, readText, TEST_FILE, toKey } from './ratchet.mjs';
|
||||
|
||||
/** The phone screens every entry of the manifest is weighed against. */
|
||||
export const SCREENS = 'src/mobile/screens';
|
||||
|
||||
/**
|
||||
* Where a shared hook may live: below the views that both import it, or next
|
||||
* to a page in its folder under pages/ (use<Page> and its models), which the
|
||||
* phone shell of that page imports as well. Never in a view itself.
|
||||
*/
|
||||
const SHARED = /^src\/(?:components|hooks|utils)\/|^src\/pages\/[^/]+\//;
|
||||
|
||||
const SOURCE = /\.tsx?$/;
|
||||
const isPath = (value) => typeof value === 'string' && SOURCE.test(value) && !value.includes('\\');
|
||||
const isPathList = (value) => Array.isArray(value) && value.length > 0 && value.every(isPath);
|
||||
|
||||
/**
|
||||
* scripts/feature-pairs.json: pairs is a list of features, each with the
|
||||
* shared hooks or models that hold its logic and the desktop and phone files
|
||||
* that run on them; mobileOnly maps each phone screen file that has no
|
||||
* desktop twin, or no logic a twin could share, to the reason.
|
||||
*/
|
||||
export function manifestShape(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return 'it is not an object';
|
||||
const keys = Object.keys(value).sort().join(',');
|
||||
if (keys !== 'mobileOnly,pairs') return 'it needs exactly the keys "pairs" and "mobileOnly"';
|
||||
if (!Array.isArray(value.pairs)) return '"pairs" is not a list';
|
||||
const names = new Set();
|
||||
for (const pair of value.pairs) {
|
||||
if (!pair || typeof pair !== 'object' || typeof pair.feature !== 'string' || !pair.feature.trim())
|
||||
return `a pair without a feature name: ${JSON.stringify(pair)}`;
|
||||
if (names.has(pair.feature)) return `the feature "${pair.feature}" is listed twice`;
|
||||
names.add(pair.feature);
|
||||
for (const field of ['hooks', 'desktop', 'mobile'])
|
||||
if (!isPathList(pair[field]))
|
||||
return `"${pair.feature}" needs "${field}" as a list of source files from the client root`;
|
||||
}
|
||||
const only = value.mobileOnly;
|
||||
if (!only || typeof only !== 'object' || Array.isArray(only)) return '"mobileOnly" is not an object of file → reason';
|
||||
for (const [key, reason] of Object.entries(only)) {
|
||||
if (!isPath(key)) return `"${key}" in "mobileOnly" is not a source file from the client root`;
|
||||
if (typeof reason !== 'string' || !reason.trim()) return `"${key}" in "mobileOnly" has no reason`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The path a relative import lands on from the client root, without its extension. */
|
||||
function targetOf(root, fromFile, specifier) {
|
||||
if (!specifier.startsWith('.')) return null;
|
||||
return toKey(root, resolve(dirname(fromFile), specifier)).replace(/(?:\/index)?(?:\.[cm]?[jt]sx?)?$/, '');
|
||||
}
|
||||
|
||||
const stem = (key) => key.replace(/\.[cm]?[jt]sx?$/, '');
|
||||
|
||||
/** The modules a file imports for more than types, as paths from the root without extensions. */
|
||||
function runtimeImports(root, key) {
|
||||
const path = join(root, key);
|
||||
return new Set(
|
||||
importsOf(readText(path), path)
|
||||
.filter((imp) => !imp.typeOnly)
|
||||
.map((imp) => targetOf(root, path, imp.specifier))
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything wrong with the manifest against the tree, as lines to print:
|
||||
* a listed file that is gone, a hook outside the shared layers, a view of a
|
||||
* pair that does not import every hook of the pair, a phone screen listed
|
||||
* both as a pair and as phone only, and a phone screen that is in neither.
|
||||
*/
|
||||
export function problems(root, manifest) {
|
||||
const found = [];
|
||||
const listed = new Set();
|
||||
const present = (key, where) => {
|
||||
if (existsSync(join(root, key))) return true;
|
||||
found.push(`${key} is listed under ${where} but does not exist`);
|
||||
return false;
|
||||
};
|
||||
|
||||
for (const pair of manifest.pairs) {
|
||||
const where = `"${pair.feature}"`;
|
||||
const hooks = pair.hooks.filter((key) => present(key, where));
|
||||
for (const key of hooks)
|
||||
if (!SHARED.test(key))
|
||||
found.push(`${key}, the hook of ${where}, is not under src/components, src/hooks, src/utils or a page folder`);
|
||||
for (const key of [...pair.desktop, ...pair.mobile]) {
|
||||
if (pair.mobile.includes(key)) listed.add(key);
|
||||
if (!present(key, where)) continue;
|
||||
const imported = runtimeImports(root, key);
|
||||
const missing = hooks.filter((hook) => !imported.has(stem(hook)));
|
||||
for (const hook of missing) found.push(`${key} is a view of ${where} but does not import ${hook}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Object.keys(manifest.mobileOnly)) {
|
||||
if (!present(key, 'mobileOnly')) continue;
|
||||
if (listed.has(key)) found.push(`${key} is listed in a pair and under mobileOnly; it is one or the other`);
|
||||
listed.add(key);
|
||||
}
|
||||
|
||||
const screens = listFiles(
|
||||
root,
|
||||
[SCREENS],
|
||||
(key) => SOURCE.test(key) && !TEST_FILE.test(key) && !key.endsWith('.d.ts')
|
||||
);
|
||||
for (const path of screens) {
|
||||
const key = toKey(root, path);
|
||||
if (!listed.has(key)) found.push(`${key} is a phone screen file that scripts/feature-pairs.json does not list`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Runs the check against the manifest at manifestPath and returns the exit code. */
|
||||
export function check({
|
||||
root,
|
||||
manifestPath = join(root, 'scripts/feature-pairs.json'),
|
||||
list = false,
|
||||
log = console.log,
|
||||
error = console.error,
|
||||
}) {
|
||||
const manifest = readBaseline(manifestPath, manifestShape);
|
||||
const found = problems(root, manifest);
|
||||
|
||||
if (list) {
|
||||
for (const pair of manifest.pairs) log(`${pair.feature}: ${pair.hooks.join(', ')}`);
|
||||
}
|
||||
for (const line of found) error(`FAIL ${line}`);
|
||||
if (found.length) {
|
||||
error(
|
||||
'A feature that has a desktop view and a phone view keeps its logic in one shared hook both import. ' +
|
||||
'Add a new phone screen to scripts/feature-pairs.json: as a view of the pair whose hook it runs on, ' +
|
||||
'or under mobileOnly with the reason it has no twin. See client/CLAUDE.md, "Desktop and phone views".'
|
||||
);
|
||||
}
|
||||
const only = Object.keys(manifest.mobileOnly).length;
|
||||
log(
|
||||
`pairs: ${manifest.pairs.length} shared feature(s), ${only} phone-only screen file(s), ${found.length} problem(s)`
|
||||
);
|
||||
return found.length ? 1 : 0;
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
/*
|
||||
* The parts every ratchet in scripts/ shares: walking the tree, reading a file
|
||||
* the same way on Windows and Linux, and reading, lowering and writing a
|
||||
* baseline.
|
||||
*
|
||||
* A ratchet fails closed. A baseline that is missing, cannot be parsed or has
|
||||
* the wrong shape stops the run with its reason instead of reading as empty
|
||||
* (as empty, the check would fail with no hint at the cause, and --update
|
||||
* would write an empty file over the real one). A directory that should be
|
||||
* scanned and is not there, or a scan that finds no file at all, stops it too:
|
||||
* a check that looked at nothing must not report green.
|
||||
*/
|
||||
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { basename, join, relative } from 'node:path';
|
||||
|
||||
/** A problem with the check itself rather than a violation it found. */
|
||||
export class RatchetError extends Error {}
|
||||
|
||||
export const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
||||
|
||||
/** Every file under root/<dir> for each dir whose path passes accept, as absolute paths. */
|
||||
export function listFiles(root, dirs, accept) {
|
||||
const files = [];
|
||||
const walk = (dir) => {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name === 'node_modules') continue;
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) walk(path);
|
||||
else if (accept(toKey(root, path))) files.push(path);
|
||||
}
|
||||
};
|
||||
for (const dir of dirs) {
|
||||
const path = join(root, dir);
|
||||
if (!existsSync(path) || !statSync(path).isDirectory()) {
|
||||
throw new RatchetError(`${dir}/ does not exist under ${root}, so there is nothing to check`);
|
||||
}
|
||||
walk(path);
|
||||
}
|
||||
if (!files.length) throw new RatchetError(`no file to check under ${dirs.map((d) => `${d}/`).join(', ')}`);
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
/** A path relative to root with forward slashes, the form every baseline uses. */
|
||||
export function toKey(root, path) {
|
||||
return relative(root, path).split('\\').join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* A text file as the repository holds it: without a byte order mark and with
|
||||
* LF line ends, so a Windows checkout with CRLF counts the same as CI.
|
||||
*/
|
||||
export function readText(path) {
|
||||
const text = readFileSync(path, 'utf8');
|
||||
return (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text).replace(/\r\n?/g, '\n');
|
||||
}
|
||||
|
||||
const isCount = (n) => Number.isInteger(n) && n > 0;
|
||||
|
||||
/** A JSON object of file → positive count. */
|
||||
export function countMap(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return 'it is not an object of file → count';
|
||||
for (const [key, n] of Object.entries(value))
|
||||
if (!isCount(n)) return `"${key}" holds ${JSON.stringify(n)}, not a positive whole number`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A JSON array of distinct file paths. */
|
||||
export function fileList(value) {
|
||||
if (!Array.isArray(value)) return 'it is not an array of file paths';
|
||||
const seen = new Set();
|
||||
for (const entry of value) {
|
||||
if (typeof entry !== 'string' || !entry) return `${JSON.stringify(entry)} is not a file path`;
|
||||
if (seen.has(entry)) return `"${entry}" is listed twice`;
|
||||
seen.add(entry);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The baseline as committed, checked against shape (one of the validators
|
||||
* above, which returns null when the value fits and the reason otherwise).
|
||||
*/
|
||||
export function readBaseline(path, shape) {
|
||||
const name = basename(path);
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(path, 'utf8');
|
||||
} catch (err) {
|
||||
throw new RatchetError(`scripts/${name} cannot be read (${err.code ?? err.message}); restore it from git`);
|
||||
}
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch (err) {
|
||||
throw new RatchetError(`scripts/${name} is not valid JSON (${err.message}); restore it from git`);
|
||||
}
|
||||
const problem = shape(value);
|
||||
if (problem) throw new RatchetError(`scripts/${name} has the wrong shape: ${problem}; restore it from git`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Writes value as sorted, two-space JSON with a final newline. */
|
||||
export function writeBaseline(path, value) {
|
||||
const sorted = Array.isArray(value)
|
||||
? [...value].sort()
|
||||
: Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
||||
writeFileSync(path, JSON.stringify(sorted, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* The baseline lowered to the counts measured now. An entry never rises, a
|
||||
* file that is not in the baseline never enters it, and an entry whose count
|
||||
* fell to floor or below leaves. floor is a number, or a function of the key
|
||||
* when the limit differs between files.
|
||||
*
|
||||
* @param {Record<string, number>} baseline
|
||||
* @param {Record<string, number>} counts
|
||||
* @param {number | ((key: string) => number)} [floor]
|
||||
*/
|
||||
export function lowerCounts(baseline, counts, floor = 0) {
|
||||
const lowered = {};
|
||||
for (const [key, allowed] of Object.entries(baseline)) {
|
||||
const now = counts[key] ?? 0;
|
||||
if (now > (typeof floor === 'function' ? floor(key) : floor)) lowered[key] = Math.min(allowed, now);
|
||||
}
|
||||
return lowered;
|
||||
}
|
||||
|
||||
/**
|
||||
* The entries of a count baseline that allow more than is measured now: a
|
||||
* count that fell below its entry, one back at floor or under it, or a file
|
||||
* that is gone. Exactly the entries lowerCounts would lower or drop. Each one
|
||||
* fails its check until --update lowers it, or the count could grow back to
|
||||
* the old entry unseen, and a deleted file would hand its allowance to the
|
||||
* next file at its path.
|
||||
*
|
||||
* @param {Record<string, number>} baseline
|
||||
* @param {Record<string, number>} counts
|
||||
* @param {number | ((key: string) => number)} [floor]
|
||||
* @returns {{ key: string, entry: number, now: number }[]}
|
||||
*/
|
||||
export function staleCounts(baseline, counts, floor = 0) {
|
||||
const lowered = lowerCounts(baseline, counts, floor);
|
||||
return Object.entries(baseline)
|
||||
.filter(([key, entry]) => lowered[key] !== entry)
|
||||
.map(([key, entry]) => ({ key, entry, now: counts[key] ?? 0 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints a FAIL line for each stale entry (see staleCounts) of the baseline
|
||||
* scripts/<file>, then how to lower them. With root, an entry whose file is
|
||||
* not there is called gone; label names an entry that is not a file path.
|
||||
*
|
||||
* @param {{ key: string, entry: number, now: number }[]} stale
|
||||
* @param {{ file: string, command: string, root?: string, label?: (key: string) => string, error: (line: string) => void }} options
|
||||
*/
|
||||
export function reportStale(stale, { file, command, root, label = (key) => key, error }) {
|
||||
for (const { key, entry, now } of stale) {
|
||||
const gone = root !== undefined && !existsSync(join(root, key));
|
||||
error(
|
||||
`FAIL ${label(key)} is held at ${entry} in scripts/${file}, ` +
|
||||
(gone ? 'but the file is gone.' : `but there are ${now} now.`)
|
||||
);
|
||||
}
|
||||
if (stale.length) {
|
||||
error(
|
||||
`Run npm run ${command} -- --update to lower the baseline with the change that made it smaller: ` +
|
||||
'an entry above the count lets it grow back unseen.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The baseline entries that are still offenders: a list only ever loses files. */
|
||||
export function lowerList(baseline, offenders) {
|
||||
const now = new Set(offenders);
|
||||
return baseline.filter((file) => now.has(file));
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one ratchet as a command. main returns the exit code; a RatchetError is
|
||||
* printed as a failure of the check itself. There is no "am I the entry
|
||||
* point" guard to get wrong: the command files only ever run, and the logic
|
||||
* they call lives in scripts/lib/ where tests import it.
|
||||
*/
|
||||
export async function runCli(name, main) {
|
||||
try {
|
||||
process.exitCode = await main(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (!(err instanceof RatchetError)) throw err;
|
||||
console.error(`FAIL ${name}: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
/*
|
||||
* The matching and the comparison behind lint:rtl (scripts/rtl-lint.mjs).
|
||||
*/
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
countMap,
|
||||
listFiles,
|
||||
lowerCounts,
|
||||
readBaseline,
|
||||
readText,
|
||||
reportStale,
|
||||
staleCounts,
|
||||
TEST_FILE,
|
||||
toKey,
|
||||
writeBaseline,
|
||||
} from './ratchet.mjs';
|
||||
import { markedInComment, withoutComments } from './source.mjs';
|
||||
|
||||
// Tailwind utilities that name a physical side, with any variant prefix
|
||||
// (sm:, hover:, group-hover:) and the negative form (-ml-2).
|
||||
const CLASS = new RegExp(
|
||||
String.raw`(?<![\w-])(?:[\w-]+:)*-?(?:` +
|
||||
[
|
||||
String.raw`(?:ml|mr|pl|pr|left|right|scroll-m[lr]|scroll-p[lr])-(?:\[[^\]\s]+\]|[\w./]+)`,
|
||||
String.raw`text-(?:left|right)(?![\w-])`,
|
||||
String.raw`border-[lr](?:-[\w[\]#./()-]+)?(?![\w-])`,
|
||||
String.raw`rounded-(?:l|r|tl|tr|bl|br)(?:-[\w[\]./]+)?(?![\w-])`,
|
||||
String.raw`(?:float|clear)-(?:left|right)(?![\w-])`,
|
||||
String.raw`space-x-[\w.]+`,
|
||||
].join('|') +
|
||||
')',
|
||||
'g'
|
||||
);
|
||||
// The same sides as React style keys, and as properties in a stylesheet.
|
||||
const STYLE =
|
||||
/\b(?:marginLeft|marginRight|paddingLeft|paddingRight|borderLeft\w*|borderRight\w*|border(?:Top|Bottom)(?:Left|Right)Radius|left|right)\s*:|\b(?:textAlign|float)\s*:\s*['"](?:left|right)['"]/g;
|
||||
const CSS =
|
||||
/(?<![\w-])(?:margin-left|margin-right|padding-left|padding-right|border-left[\w-]*|border-right[\w-]*|border-(?:top|bottom)-(?:left|right)-radius|left|right)\s*:|(?<![\w-])(?:text-align|float)\s*:\s*(?:left|right)\b/g;
|
||||
|
||||
export const DISABLE = 'rtl-lint-disable';
|
||||
|
||||
/**
|
||||
* One file read once: every physical use as `line: match`, skipping the lines
|
||||
* marked as physical on purpose, and how many lines carry that mark.
|
||||
*/
|
||||
export function inspect(source, file) {
|
||||
const isCss = file.endsWith('.css');
|
||||
const uses = [];
|
||||
let markers = 0;
|
||||
const lines = source.split('\n');
|
||||
withoutComments(source, file)
|
||||
.split('\n')
|
||||
.forEach((code, i) => {
|
||||
if (markedInComment(lines[i], code, DISABLE)) {
|
||||
markers++;
|
||||
return;
|
||||
}
|
||||
const patterns = isCss ? [CSS] : [CLASS, STYLE];
|
||||
for (const re of patterns) for (const m of code.match(re) ?? []) uses.push(`${i + 1}: ${m.trim()}`);
|
||||
});
|
||||
return { uses, markers };
|
||||
}
|
||||
|
||||
/** Every physical use in one file, as `line: match`. */
|
||||
export function physicalUses(source, file) {
|
||||
return inspect(source, file).uses;
|
||||
}
|
||||
|
||||
/** The physical uses and the disable markers per file under root/src, keyed by the path from src/. */
|
||||
export function scan(root) {
|
||||
const counts = {};
|
||||
const listed = {};
|
||||
const markers = {};
|
||||
const accept = (key) => /\.(?:tsx?|css)$/.test(key) && !TEST_FILE.test(key);
|
||||
for (const path of listFiles(root, ['src'], accept)) {
|
||||
const found = inspect(readText(path), path);
|
||||
const key = toKey(join(root, 'src'), path);
|
||||
if (found.uses.length) {
|
||||
counts[key] = found.uses.length;
|
||||
listed[key] = found.uses;
|
||||
}
|
||||
if (found.markers) markers[key] = found.markers;
|
||||
}
|
||||
return { counts, listed, markers };
|
||||
}
|
||||
|
||||
const sum = (counts) => Object.values(counts).reduce((a, b) => a + b, 0);
|
||||
|
||||
/**
|
||||
* Runs the check against both baselines: rtl-baseline.json for the physical
|
||||
* sides, rtl-disable-baseline.json for the lines that opt out of the check. A
|
||||
* marker hides a side from the count, so the markers are a ratchet as well:
|
||||
* a file holds no more of them than its entry. Returns the exit code.
|
||||
*/
|
||||
export function check({
|
||||
root,
|
||||
baselinePath = join(root, 'scripts/rtl-baseline.json'),
|
||||
disableBaselinePath = join(root, 'scripts/rtl-disable-baseline.json'),
|
||||
update = false,
|
||||
list = false,
|
||||
log = console.log,
|
||||
error = console.error,
|
||||
}) {
|
||||
const { counts, listed, markers } = scan(root);
|
||||
let baseline = readBaseline(baselinePath, countMap);
|
||||
let allowedMarkers = readBaseline(disableBaselinePath, countMap);
|
||||
|
||||
if (list) {
|
||||
for (const [file, uses] of Object.entries(listed).sort()) {
|
||||
log(`${file} (${uses.length}, baseline ${baseline[file] ?? 0})`);
|
||||
for (const use of uses) log(` ${use}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (update) {
|
||||
baseline = lowerCounts(baseline, counts);
|
||||
allowedMarkers = lowerCounts(allowedMarkers, markers);
|
||||
writeBaseline(baselinePath, baseline);
|
||||
writeBaseline(disableBaselinePath, allowedMarkers);
|
||||
}
|
||||
|
||||
const grown = Object.entries(counts).filter(([file, n]) => n > (baseline[file] ?? 0));
|
||||
const marked = Object.entries(markers).filter(([file, n]) => n > (allowedMarkers[file] ?? 0));
|
||||
const stale = staleCounts(baseline, counts);
|
||||
const staleMarkers = staleCounts(allowedMarkers, markers);
|
||||
|
||||
for (const [file, n] of grown) {
|
||||
error(
|
||||
`FAIL ${file}: ${n} physical side(s), baseline ${baseline[file] ?? 0}. ` +
|
||||
'Use ms-/me-/ps-/pe-/start-/end-/text-start/text-end/border-s/border-e/rounded-s/rounded-e ' +
|
||||
'or marginInlineStart/paddingInlineEnd/insetInlineStart/textAlign: start. ' +
|
||||
`If the side is geometry, mark the line with ${DISABLE} in a comment.`
|
||||
);
|
||||
}
|
||||
for (const [file, n] of marked) {
|
||||
error(
|
||||
`FAIL ${file}: ${n} line(s) marked ${DISABLE}, scripts/rtl-disable-baseline.json allows ${allowedMarkers[file] ?? 0}. ` +
|
||||
'Use the logical form where the side follows the text. A side that is geometry on purpose ' +
|
||||
'(a map, a measured position, a time axis) needs a reviewer to raise the entry by hand.'
|
||||
);
|
||||
}
|
||||
reportStale(stale, { file: 'rtl-baseline.json', command: 'lint:rtl', root: join(root, 'src'), error });
|
||||
reportStale(staleMarkers, { file: 'rtl-disable-baseline.json', command: 'lint:rtl', root: join(root, 'src'), error });
|
||||
log(
|
||||
`rtl: ${sum(counts)} physical side(s) in ${Object.keys(counts).length} file(s), baseline allows ${sum(baseline)}; ` +
|
||||
`${sum(markers)} line(s) marked ${DISABLE}, baseline allows ${sum(allowedMarkers)}`
|
||||
);
|
||||
return grown.length || marked.length || stale.length || staleMarkers.length ? 1 : 0;
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
/*
|
||||
* The measuring and the comparison behind lint:size (scripts/size-lint.mjs).
|
||||
*
|
||||
* A file's size is its lines as an editor wraps them at WIDTH columns: a line
|
||||
* up to WIDTH characters counts once, a longer one once per WIDTH characters
|
||||
* it spans. Counting newlines alone let a file stay under its entry by joining
|
||||
* lines, and the trip planner page already held a 3760 character one; WIDTH is
|
||||
* the printWidth in .prettierrc, so formatted code counts what it shows.
|
||||
*/
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
countMap,
|
||||
listFiles,
|
||||
lowerCounts,
|
||||
readBaseline,
|
||||
readText,
|
||||
reportStale,
|
||||
staleCounts,
|
||||
TEST_FILE,
|
||||
toKey,
|
||||
writeBaseline,
|
||||
} from './ratchet.mjs';
|
||||
|
||||
export const WIDTH = 120;
|
||||
|
||||
/**
|
||||
* The kinds of file the check covers, each with the size a file without a
|
||||
* baseline entry may reach. Tests get more room: a spec mirrors the component
|
||||
* it covers and holds fixtures, but past LIMIT it is a god file too. The
|
||||
* Playwright specs and their fixtures under e2e/ count as tests.
|
||||
*/
|
||||
export const GROUPS = [
|
||||
{
|
||||
name: 'test',
|
||||
limit: 2000,
|
||||
accepts: (key) =>
|
||||
/\.tsx?$/.test(key) && (key.startsWith('tests/') || key.startsWith('e2e/') || TEST_FILE.test(key)),
|
||||
},
|
||||
{
|
||||
name: 'source',
|
||||
limit: 1000,
|
||||
accepts: (key) => key.startsWith('src/') && /\.tsx?$/.test(key) && !TEST_FILE.test(key),
|
||||
},
|
||||
{ name: 'stylesheet', limit: 1000, accepts: (key) => key.startsWith('src/') && key.endsWith('.css') },
|
||||
];
|
||||
|
||||
export const DIRS = ['src', 'tests', 'e2e'];
|
||||
|
||||
export function groupOf(key) {
|
||||
return GROUPS.find((group) => group.accepts(key)) ?? null;
|
||||
}
|
||||
|
||||
/** The size of a text as wrapped at WIDTH; a final newline does not start another line. */
|
||||
export function sizeOf(text) {
|
||||
if (text === '') return 0;
|
||||
const lines = text.replace(/\r\n?/g, '\n').split('\n');
|
||||
if (text.endsWith('\n')) lines.pop();
|
||||
return lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / WIDTH)), 0);
|
||||
}
|
||||
|
||||
/** Every covered file under root with its size, keyed by its path from root. */
|
||||
export function measure(root) {
|
||||
const sizes = {};
|
||||
for (const path of listFiles(root, DIRS, (key) => groupOf(key) !== null))
|
||||
sizes[toKey(root, path)] = sizeOf(readText(path));
|
||||
return sizes;
|
||||
}
|
||||
|
||||
const limitOf = (key) => groupOf(key)?.limit ?? 0;
|
||||
|
||||
/**
|
||||
* Runs the check against the baseline at baselinePath. With update, the
|
||||
* baseline is lowered first. An entry above the file's size now, or for a
|
||||
* file that is gone, fails as well until --update lowers it. Returns the
|
||||
* exit code.
|
||||
*/
|
||||
export function check({
|
||||
root,
|
||||
baselinePath = join(root, 'scripts/size-baseline.json'),
|
||||
update = false,
|
||||
log = console.log,
|
||||
error = console.error,
|
||||
}) {
|
||||
const sizes = measure(root);
|
||||
let baseline = readBaseline(baselinePath, countMap);
|
||||
|
||||
if (update) {
|
||||
baseline = lowerCounts(baseline, sizes, limitOf);
|
||||
writeBaseline(baselinePath, baseline);
|
||||
}
|
||||
|
||||
const grown = Object.entries(sizes).filter(([key, n]) => n > Math.max(baseline[key] ?? 0, limitOf(key)));
|
||||
const stale = staleCounts(baseline, sizes, limitOf);
|
||||
|
||||
for (const [key, n] of grown) {
|
||||
const entry = baseline[key];
|
||||
error(
|
||||
`FAIL ${key}: ${n} lines (wrapped at ${WIDTH} columns), ` +
|
||||
(entry ? `its baseline is ${entry}. ` : `the limit for a ${groupOf(key).name} file is ${limitOf(key)}. `) +
|
||||
'Move a concern into a module of its own instead of growing the file or packing its lines.'
|
||||
);
|
||||
}
|
||||
reportStale(stale, { file: 'size-baseline.json', command: 'lint:size', root, error });
|
||||
log(
|
||||
`size: ${Object.keys(sizes).length} file(s), ${Object.keys(baseline).length} past their limit held at their baseline`
|
||||
);
|
||||
return grown.length || stale.length ? 1 : 0;
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
/*
|
||||
* The matching and the comparison behind lint:skips (scripts/skip-lint.mjs).
|
||||
*/
|
||||
import { join } from 'node:path';
|
||||
import ts from 'typescript';
|
||||
import {
|
||||
countMap,
|
||||
listFiles,
|
||||
lowerCounts,
|
||||
readBaseline,
|
||||
readText,
|
||||
reportStale,
|
||||
staleCounts,
|
||||
TEST_FILE,
|
||||
toKey,
|
||||
writeBaseline,
|
||||
} from './ratchet.mjs';
|
||||
|
||||
/** The test functions of vitest and Playwright a modifier can hang off. */
|
||||
const RUNNERS = new Set(['it', 'test', 'describe', 'suite', 'bench']);
|
||||
/** The jasmine-style shorthands vitest also knows. */
|
||||
const SHORTHAND = new Set(['xit', 'xtest', 'xdescribe']);
|
||||
|
||||
export const DIRS = ['src', 'tests', 'e2e'];
|
||||
|
||||
const accepts = (key) => /\.tsx?$/.test(key) && (key.startsWith('src/') ? TEST_FILE.test(key) : true);
|
||||
|
||||
/** The identifier a chain like test.describe.skip.each starts from. */
|
||||
function rootOf(node) {
|
||||
while (ts.isPropertyAccessExpression(node) || ts.isCallExpression(node)) node = node.expression;
|
||||
return ts.isIdentifier(node) ? node.text : null;
|
||||
}
|
||||
|
||||
/** Playwright specs live under e2e/; everything else runs under vitest. */
|
||||
const isPlaywright = (file) => file.startsWith('e2e/');
|
||||
|
||||
const isTitle = (node) => Boolean(node) && (ts.isStringLiteralLike(node) || ts.isTemplateExpression(node));
|
||||
const isFunction = (node) => Boolean(node) && (ts.isArrowFunction(node) || ts.isFunctionExpression(node));
|
||||
|
||||
/**
|
||||
* Whether a skip or fixme call switches tests off for good. Under vitest it
|
||||
* always does: the first argument is the title, a literal or not. Playwright
|
||||
* also knows a run-time form, test.skip(condition, 'why'), which only skips
|
||||
* when the condition holds. A call counts there unless it is that form: it
|
||||
* declares a test (a title first or a test body second), it has no argument
|
||||
* (called bare, it skips the test or group it sits in), or its condition is
|
||||
* the literal true.
|
||||
*/
|
||||
function switchesOff(call, file) {
|
||||
const [first, second] = call.arguments;
|
||||
if (!isPlaywright(file) || !first || isTitle(first) || isFunction(second)) return true;
|
||||
return first.kind === ts.SyntaxKind.TrueKeyword;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tests a file switches off or narrows down, as `line: code`.
|
||||
*
|
||||
* skipped: it.skip / describe.skip / test.todo / test.fixme and the
|
||||
* x-shorthands, each switching off a test that never runs. Playwright's
|
||||
* test.skip(!seed.id, 'why') decides at run time and does not count (see
|
||||
* switchesOff); neither do skipIf and runIf, unless their condition is the
|
||||
* literal that always skips: skipIf(true), runIf(false).
|
||||
*
|
||||
* only: .only anywhere, which silently drops every other test in the file.
|
||||
*
|
||||
* file is the path from the client root: it decides vitest or Playwright.
|
||||
*/
|
||||
export function modifiers(source, file) {
|
||||
const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
||||
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind);
|
||||
const skipped = [];
|
||||
const only = [];
|
||||
const at = (node) =>
|
||||
`${sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1}: ${node.getText(sf).slice(0, 60)}`;
|
||||
const visit = (node) => {
|
||||
if (ts.isPropertyAccessExpression(node) && RUNNERS.has(rootOf(node.expression) ?? '')) {
|
||||
const name = node.name.text;
|
||||
if (name === 'only') only.push(at(node));
|
||||
else if (name === 'todo') skipped.push(at(node));
|
||||
else if (name === 'skip' || name === 'fixme') {
|
||||
const call = ts.isCallExpression(node.parent) && node.parent.expression === node ? node.parent : null;
|
||||
// test.skip.each(table)(...) declares skipped tests without being called directly.
|
||||
if (!call || switchesOff(call, file)) skipped.push(at(node));
|
||||
} else if (name === 'skipIf' || name === 'runIf') {
|
||||
// skipIf(true) and runIf(false) are a skip in disguise: the condition can never change.
|
||||
const call = ts.isCallExpression(node.parent) && node.parent.expression === node ? node.parent : null;
|
||||
const never = name === 'skipIf' ? ts.SyntaxKind.TrueKeyword : ts.SyntaxKind.FalseKeyword;
|
||||
if (call?.arguments[0]?.kind === never) skipped.push(at(node));
|
||||
}
|
||||
} else if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && SHORTHAND.has(node.expression.text)) {
|
||||
skipped.push(at(node.expression));
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sf);
|
||||
return { skipped, only };
|
||||
}
|
||||
|
||||
/** The skipped and focused tests per file under src/, tests/ and e2e/, keyed by the path from root. */
|
||||
export function scan(root) {
|
||||
const skipped = {};
|
||||
const only = {};
|
||||
for (const path of listFiles(root, DIRS, accepts)) {
|
||||
const key = toKey(root, path);
|
||||
const found = modifiers(readText(path), key);
|
||||
if (found.skipped.length) skipped[key] = found.skipped;
|
||||
if (found.only.length) only[key] = found.only;
|
||||
}
|
||||
return { skipped, only };
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the check: no .only anywhere, and no file with more skipped tests than
|
||||
* its entry in the baseline at baselinePath. Returns the exit code.
|
||||
*/
|
||||
export function check({
|
||||
root,
|
||||
baselinePath = join(root, 'scripts/skip-baseline.json'),
|
||||
update = false,
|
||||
log = console.log,
|
||||
error = console.error,
|
||||
}) {
|
||||
const { skipped, only } = scan(root);
|
||||
const counts = Object.fromEntries(Object.entries(skipped).map(([key, list]) => [key, list.length]));
|
||||
let baseline = readBaseline(baselinePath, countMap);
|
||||
|
||||
if (update) {
|
||||
baseline = lowerCounts(baseline, counts);
|
||||
writeBaseline(baselinePath, baseline);
|
||||
}
|
||||
|
||||
for (const [key, list] of Object.entries(only)) {
|
||||
error(`FAIL ${key}: .only runs this test alone and drops every other one in the file:`);
|
||||
for (const line of list) error(` ${line}`);
|
||||
}
|
||||
const grown = Object.entries(counts).filter(([key, n]) => n > (baseline[key] ?? 0));
|
||||
for (const [key, n] of grown) {
|
||||
error(`FAIL ${key}: ${n} skipped or todo test(s), baseline ${baseline[key] ?? 0}:`);
|
||||
for (const line of skipped[key]) error(` ${line}`);
|
||||
}
|
||||
if (grown.length) {
|
||||
error('Fix the test, delete it if its feature is gone, or use skipIf/runIf when it depends on the environment.');
|
||||
}
|
||||
const stale = staleCounts(baseline, counts);
|
||||
reportStale(stale, { file: 'skip-baseline.json', command: 'lint:skips', root, error });
|
||||
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
|
||||
log(
|
||||
`skips: ${sum(counts)} skipped or todo test(s), baseline allows ${sum(baseline)}; ${Object.keys(only).length} file(s) with .only`
|
||||
);
|
||||
return grown.length || stale.length || Object.keys(only).length ? 1 : 0;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* Reading source the way the line ratchets need it: with the comments blanked
|
||||
* out, and telling a disable marker in a comment from the same text in code.
|
||||
* Shared by lint:rtl (scripts/lib/rtl.mjs) and theme:lint (scripts/lib/theme.mjs).
|
||||
*/
|
||||
import ts from 'typescript';
|
||||
|
||||
/**
|
||||
* The source with its comments blanked out, so prose like "top right" or
|
||||
* "left out" is no side. TypeScript finds the comments in a .ts/.tsx file,
|
||||
* which keeps `/*` and `//` inside strings and JSX text where they belong
|
||||
* (accept="image/*", a URL); a stylesheet only has block comments.
|
||||
*/
|
||||
export function withoutComments(source, file) {
|
||||
const chars = source.split('');
|
||||
const blank = (pos, end) => {
|
||||
for (let i = pos; i < end; i++) if (chars[i] !== '\n') chars[i] = ' ';
|
||||
};
|
||||
if (file.endsWith('.css')) {
|
||||
for (const m of source.matchAll(/\/\*[\s\S]*?\*\//g)) blank(m.index, m.index + m[0].length);
|
||||
return chars.join('');
|
||||
}
|
||||
const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
||||
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind);
|
||||
const seen = new Set();
|
||||
const take = (ranges) => {
|
||||
for (const r of ranges ?? []) {
|
||||
if (seen.has(r.pos)) continue;
|
||||
seen.add(r.pos);
|
||||
blank(r.pos, r.end);
|
||||
}
|
||||
};
|
||||
const visit = (node) => {
|
||||
take(ts.getLeadingCommentRanges(source, node.pos));
|
||||
take(ts.getTrailingCommentRanges(source, node.end));
|
||||
// JSX text holds no comments, and asking for them there would read `//` in a URL as one.
|
||||
if (!ts.isJsxText(node)) for (const child of node.getChildren(sf)) visit(child);
|
||||
};
|
||||
visit(sf);
|
||||
take(ts.getLeadingCommentRanges(source, sf.endOfFileToken.pos));
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether line carries marker inside a comment; in a string or in code it is no
|
||||
* marker. code is the same line from withoutComments.
|
||||
*/
|
||||
export function markedInComment(line, code, marker) {
|
||||
for (let at = line.indexOf(marker); at !== -1; at = line.indexOf(marker, at + 1)) {
|
||||
if (!code.slice(at, at + marker.length).trim()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
/*
|
||||
* The tally and the comparison behind lint:strict (scripts/strict-lint.mjs).
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
countMap,
|
||||
lowerCounts,
|
||||
RatchetError,
|
||||
readBaseline,
|
||||
reportStale,
|
||||
staleCounts,
|
||||
writeBaseline,
|
||||
} from './ratchet.mjs';
|
||||
|
||||
// `src/a.ts(12,5): error TS2531: Object is possibly 'null'.` as tsc prints it with --pretty false.
|
||||
const FILE_ERROR = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/;
|
||||
// An error tied to no file, such as a broken tsconfig, starts the same way without the position.
|
||||
const OTHER_ERROR = /^(?:.+?: )?error (TS\d+): (.*)$/;
|
||||
|
||||
/**
|
||||
* The strict errors per file in tsc's output, with each error's line, and the
|
||||
* errors tied to no file (a broken config), which always fail. A file key is
|
||||
* the path tsc printed, relative to the client root, with forward slashes; the
|
||||
* indented lines that continue a message belong to the error above them.
|
||||
|
||||
*
|
||||
* @param {string} output
|
||||
* @returns {{ counts: Record<string, number>, listed: Record<string, string[]>, general: string[] }}
|
||||
*/
|
||||
export function parseTscOutput(output) {
|
||||
const counts = {};
|
||||
const listed = {};
|
||||
const general = [];
|
||||
for (const line of output.replace(/\r\n?/g, '\n').split('\n')) {
|
||||
const hit = FILE_ERROR.exec(line);
|
||||
if (hit) {
|
||||
const key = hit[1].split('\\').join('/');
|
||||
counts[key] = (counts[key] ?? 0) + 1;
|
||||
(listed[key] ??= []).push(`${hit[2]}:${hit[3]} ${hit[4]} ${hit[5]}`);
|
||||
continue;
|
||||
}
|
||||
if (!/^\s/.test(line) && OTHER_ERROR.test(line)) general.push(line.trim());
|
||||
}
|
||||
return { counts, listed, general };
|
||||
}
|
||||
|
||||
/** tsc over tsconfig.strict.json, as its plain-text output. */
|
||||
export function runTsc(root) {
|
||||
const tsc = createRequire(join(root, 'package.json')).resolve('typescript/bin/tsc');
|
||||
const result = spawnSync(process.execPath, [tsc, '-p', 'tsconfig.strict.json', '--noEmit', '--pretty', 'false'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 256 * 1024 * 1024,
|
||||
});
|
||||
if (result.error) throw new RatchetError(`tsc did not run: ${result.error.message}`);
|
||||
// tsc exits 0 when clean and 1 or 2 when it reports errors; anything else is a crash.
|
||||
if (![0, 1, 2].includes(result.status ?? -1))
|
||||
throw new RatchetError(
|
||||
`tsc stopped with exit code ${result.status}: ${(result.stderr || result.stdout).slice(0, 500)}`
|
||||
);
|
||||
return `${result.stdout}\n${result.stderr}`;
|
||||
}
|
||||
|
||||
const sum = (counts) => Object.values(counts).reduce((a, b) => a + b, 0);
|
||||
|
||||
/**
|
||||
* Runs tsc (or tsc, in a test) and compares the strict errors per file with
|
||||
* the baseline at baselinePath. Returns the exit code.
|
||||
*/
|
||||
export function check({
|
||||
root,
|
||||
baselinePath = join(root, 'scripts/strict-baseline.json'),
|
||||
update = false,
|
||||
list = false,
|
||||
tsc = runTsc,
|
||||
log = console.log,
|
||||
error = console.error,
|
||||
}) {
|
||||
let baseline = readBaseline(baselinePath, countMap);
|
||||
const { counts, listed, general } = parseTscOutput(tsc(root));
|
||||
|
||||
if (list) {
|
||||
for (const [file, errors] of Object.entries(listed).sort()) {
|
||||
log(`${file} (${errors.length}, baseline ${baseline[file] ?? 0})`);
|
||||
for (const line of errors) log(` ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (update) {
|
||||
baseline = lowerCounts(baseline, counts);
|
||||
writeBaseline(baselinePath, baseline);
|
||||
}
|
||||
|
||||
for (const line of general) error(`FAIL ${line}`);
|
||||
const grown = Object.entries(counts).filter(([file, n]) => n > (baseline[file] ?? 0));
|
||||
for (const [file, n] of grown) {
|
||||
const first = listed[file].slice(0, 3).join('; ');
|
||||
error(
|
||||
`FAIL ${file}: ${n} error(s) under strictNullChecks and noImplicitAny, baseline ${baseline[file] ?? 0}. ` +
|
||||
`First: ${first}`
|
||||
);
|
||||
}
|
||||
if (grown.length) {
|
||||
error(
|
||||
'Handle the null or type the value rather than asserting it away with `!` or `any`: both are counted by ' +
|
||||
'lint:warnings. Run npm run lint:strict -- --list for every error.'
|
||||
);
|
||||
}
|
||||
const stale = staleCounts(baseline, counts);
|
||||
reportStale(stale, { file: 'strict-baseline.json', command: 'lint:strict', root, error });
|
||||
log(
|
||||
`strict: ${sum(counts)} error(s) in ${Object.keys(counts).length} file(s) under strictNullChecks and ` +
|
||||
`noImplicitAny, baseline allows ${sum(baseline)}`
|
||||
);
|
||||
return general.length || grown.length || stale.length ? 1 : 0;
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
/*
|
||||
* The matching and the comparison behind theme:lint (scripts/theme-lint.mjs).
|
||||
*/
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
countMap,
|
||||
listFiles,
|
||||
lowerCounts,
|
||||
readBaseline,
|
||||
readText,
|
||||
reportStale,
|
||||
staleCounts,
|
||||
TEST_FILE,
|
||||
toKey,
|
||||
writeBaseline,
|
||||
} from './ratchet.mjs';
|
||||
import { markedInComment, withoutComments } from './source.mjs';
|
||||
|
||||
export const DISABLE = 'theme-lint-disable';
|
||||
|
||||
// A Tailwind variant prefix (dark:, hover:, md:, group-hover:) and the important mark.
|
||||
const VARIANT = String.raw`(?<![\w-])(?:[\w-]+:)*!?`;
|
||||
const PALETTE =
|
||||
'slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose';
|
||||
const COLOR_UTILITY = String.raw`bg|text|border(?:-[trblxyse])?|ring(?:-offset)?|fill|stroke|from|via|to|divide|outline|decoration|placeholder|caret|accent|shadow`;
|
||||
|
||||
/**
|
||||
* The z-index steps in src/index.css start at 60 (--z-bar). A literal above
|
||||
* Tailwind's own z-50 is a fixed surface placing itself by a number of its own
|
||||
* instead of docking onto a step; z-0 to z-50 stay free for stacking inside a
|
||||
* component.
|
||||
*/
|
||||
export const Z_LITERAL_ABOVE = 50;
|
||||
|
||||
/**
|
||||
* Each rule of the contract in src/theme/README.md the check can see in source,
|
||||
* with the fix its failure message names. A rule returns the offending matches
|
||||
* in one line of code (comments already blanked out).
|
||||
*/
|
||||
export const RULES = [
|
||||
{
|
||||
name: 'arbitrary-color',
|
||||
fix: 'a token utility (bg-surface*, text-content*, border-edge*, bg-accent) or bg-[var(--token)]',
|
||||
re: new RegExp(
|
||||
String.raw`\b(?:bg|text|border|ring|fill|stroke|from|via|to|shadow|outline|decoration|divide|caret)-\[\s*(?:#|rgba?\(|hsla?\(|oklch\()[^\]]*\]`,
|
||||
'g'
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'inline-color',
|
||||
fix: 'var(--token) in the style, or the token utility as a class',
|
||||
re: /(?: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\()/g,
|
||||
},
|
||||
{
|
||||
name: 'inline-font-size',
|
||||
fix: 'a type tier (text-title, text-subtitle, text-body, text-caption)',
|
||||
re: /fontSize\s*:\s*['"`]?\d/g,
|
||||
},
|
||||
{
|
||||
name: 'palette-class',
|
||||
fix: 'a token utility: bg-surface*, text-content*, border-edge*, bg-accent with text-accent-text, or a status color',
|
||||
re: new RegExp(
|
||||
`${VARIANT}(?:${COLOR_UTILITY})-(?:(?:${PALETTE})-\\d{2,3}|white|black)(?:\\/\\d{1,3})?(?![\\w-])`,
|
||||
'g'
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'raw-text-size',
|
||||
fix: 'a type tier (text-title, text-subtitle, text-body, text-caption), which follows the text size setting',
|
||||
// The named sizes and the arbitrary ones (text-[13px], text-[0.8rem]) alike.
|
||||
re: new RegExp(`${VARIANT}text-(?:xs|sm|base|lg|xl|[2-9]xl|\\[\\d*\\.?\\d+(?:px|rem|em)\\])(?![\\w-])`, 'g'),
|
||||
},
|
||||
{
|
||||
name: 'z-index-literal',
|
||||
fix: 'a step of the layering scale: z-[var(--z-modal)] as a class, zIndex: "var(--z-modal)" inline',
|
||||
match: (code) => {
|
||||
const found = [];
|
||||
const add = (m, n) => {
|
||||
if (Math.abs(Number(n)) > Z_LITERAL_ABOVE) found.push(m.trim());
|
||||
};
|
||||
for (const m of code.matchAll(new RegExp(`${VARIANT}-?z-(?:\\[(-?\\d+)\\]|(\\d+))(?![\\w-])`, 'g')))
|
||||
add(m[0], m[1] ?? m[2]);
|
||||
for (const m of code.matchAll(/\b(?:zIndex\s*:|z-index\s*:)\s*['"`]?\s*(-?\d+)/g)) add(m[0], m[1]);
|
||||
return found;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dark-mode-read',
|
||||
fix: 'the .dark class (useIsDark, dark: variants); only applyAppearance reads the setting',
|
||||
re: /\??\.dark_mode\b/g,
|
||||
},
|
||||
];
|
||||
|
||||
const matchesOf = (rule, code) => (rule.match ? rule.match(code) : [...code.matchAll(rule.re)].map((m) => m[0].trim()));
|
||||
|
||||
/**
|
||||
* One file read once: every hit as `line: rule: match`, skipping the lines
|
||||
* that carry the marker in a comment, and how many lines carry it.
|
||||
*/
|
||||
export function inspect(source, file) {
|
||||
const hits = [];
|
||||
let markers = 0;
|
||||
const lines = source.split('\n');
|
||||
withoutComments(source, file)
|
||||
.split('\n')
|
||||
.forEach((code, i) => {
|
||||
if (markedInComment(lines[i], code, DISABLE)) {
|
||||
markers++;
|
||||
return;
|
||||
}
|
||||
for (const rule of RULES) for (const m of matchesOf(rule, code)) hits.push(`${i + 1}: ${rule.name}: ${m}`);
|
||||
});
|
||||
return { hits, markers };
|
||||
}
|
||||
|
||||
/** The hits and markers per file under root/src (tests excluded), keyed by the path from src/. */
|
||||
export function scan(root) {
|
||||
const counts = {};
|
||||
const listed = {};
|
||||
const markers = {};
|
||||
const accept = (key) => /\.tsx?$/.test(key) && !TEST_FILE.test(key);
|
||||
for (const path of listFiles(root, ['src'], accept)) {
|
||||
const found = inspect(readText(path), path);
|
||||
const key = toKey(join(root, 'src'), path);
|
||||
if (found.hits.length) {
|
||||
counts[key] = found.hits.length;
|
||||
listed[key] = found.hits;
|
||||
}
|
||||
if (found.markers) markers[key] = found.markers;
|
||||
}
|
||||
return { counts, listed, markers };
|
||||
}
|
||||
|
||||
const sum = (counts) => Object.values(counts).reduce((a, b) => a + b, 0);
|
||||
|
||||
/**
|
||||
* Runs the check against theme-baseline.json (hits per file) and
|
||||
* theme-disable-baseline.json (marked lines per file). Returns the exit code.
|
||||
*/
|
||||
export function check({
|
||||
root,
|
||||
baselinePath = join(root, 'scripts/theme-baseline.json'),
|
||||
disableBaselinePath = join(root, 'scripts/theme-disable-baseline.json'),
|
||||
update = false,
|
||||
list = false,
|
||||
log = console.log,
|
||||
error = console.error,
|
||||
}) {
|
||||
const { counts, listed, markers } = scan(root);
|
||||
let baseline = readBaseline(baselinePath, countMap);
|
||||
let allowedMarkers = readBaseline(disableBaselinePath, countMap);
|
||||
|
||||
if (list) {
|
||||
for (const [file, hits] of Object.entries(listed).sort()) {
|
||||
log(`${file} (${hits.length}, baseline ${baseline[file] ?? 0})`);
|
||||
for (const hit of hits) log(` ${hit}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (update) {
|
||||
baseline = lowerCounts(baseline, counts);
|
||||
allowedMarkers = lowerCounts(allowedMarkers, markers);
|
||||
writeBaseline(baselinePath, baseline);
|
||||
writeBaseline(disableBaselinePath, allowedMarkers);
|
||||
}
|
||||
|
||||
const grown = Object.entries(counts).filter(([file, n]) => n > (baseline[file] ?? 0));
|
||||
const marked = Object.entries(markers).filter(([file, n]) => n > (allowedMarkers[file] ?? 0));
|
||||
const stale = staleCounts(baseline, counts);
|
||||
const staleMarkers = staleCounts(allowedMarkers, markers);
|
||||
|
||||
for (const [file, n] of grown) {
|
||||
const rules = [...new Set(listed[file].map((hit) => hit.split(': ')[1]))];
|
||||
error(`FAIL ${file}: ${n} styling bypass(es) of the theme tokens, baseline ${baseline[file] ?? 0}.`);
|
||||
for (const name of rules) error(` ${name}: use ${RULES.find((rule) => rule.name === name).fix}.`);
|
||||
}
|
||||
if (grown.length) {
|
||||
error(
|
||||
`Run npm run theme:lint -- --list to see each hit. A colour CSS variables cannot reach (map paint, PDF, ` +
|
||||
`brand) is marked with ${DISABLE} in a comment on its line. See src/theme/README.md.`
|
||||
);
|
||||
}
|
||||
for (const [file, n] of marked) {
|
||||
error(
|
||||
`FAIL ${file}: ${n} line(s) marked ${DISABLE}, scripts/theme-disable-baseline.json allows ` +
|
||||
`${allowedMarkers[file] ?? 0}. Use a token where one reaches; a surface it cannot reach (map paint, PDF, ` +
|
||||
'brand colours) needs a reviewer to raise the entry by hand.'
|
||||
);
|
||||
}
|
||||
reportStale(stale, { file: 'theme-baseline.json', command: 'theme:lint', root: join(root, 'src'), error });
|
||||
reportStale(staleMarkers, {
|
||||
file: 'theme-disable-baseline.json',
|
||||
command: 'theme:lint',
|
||||
root: join(root, 'src'),
|
||||
error,
|
||||
});
|
||||
log(
|
||||
`theme: ${sum(counts)} styling bypass(es) in ${Object.keys(counts).length} file(s), baseline allows ${sum(baseline)}; ` +
|
||||
`${sum(markers)} line(s) marked ${DISABLE}, baseline allows ${sum(allowedMarkers)}`
|
||||
);
|
||||
return grown.length || marked.length || stale.length || staleMarkers.length ? 1 : 0;
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
[
|
||||
"src/components/Admin/addons/useAddonManager.ts",
|
||||
"src/components/Admin/addons/useLlmParsingConfig.ts",
|
||||
"src/components/Admin/plugins/usePluginDetail.ts",
|
||||
"src/components/Admin/plugins/usePluginsAdmin.ts",
|
||||
"src/components/Admin/storage/useStorageAdmin.ts",
|
||||
"src/components/Admin/useAdminNotificationMatrix.ts",
|
||||
"src/components/Admin/useAdminNotificationSettings.ts",
|
||||
"src/components/Admin/useAdminSettingsActions.ts",
|
||||
"src/components/Admin/useAdminUserActions.ts",
|
||||
"src/components/Admin/useAuditLog.ts",
|
||||
"src/components/Admin/useBackupAdmin.ts",
|
||||
"src/components/Admin/useCategoryAdmin.ts",
|
||||
"src/components/Admin/useDefaultUserSettings.ts",
|
||||
"src/components/Admin/useDevNotifications.ts",
|
||||
"src/components/Admin/useGithubReleases.ts",
|
||||
"src/components/Admin/useGoogleQuota.ts",
|
||||
"src/components/Admin/useInstanceSettings.ts",
|
||||
"src/components/Admin/useMcpTokensAdmin.ts",
|
||||
"src/components/Admin/useNotificationChannels.ts",
|
||||
"src/components/Admin/usePackingTemplateAdmin.ts",
|
||||
"src/components/Admin/usePermissionsAdmin.ts",
|
||||
"src/components/BackgroundTasks/BackgroundTasksWidget.tsx",
|
||||
"src/components/Budget/ReceiptPreviewModal.tsx",
|
||||
"src/components/Budget/receiptUploads.ts",
|
||||
"src/components/Collab/CollabChatLinkPreview.tsx",
|
||||
"src/components/Collab/CollabLinks.tsx",
|
||||
"src/components/Collab/CollabNotes.tsx",
|
||||
"src/components/Collab/CollabNotesAuthedImg.tsx",
|
||||
"src/components/Collab/CollabNotesFilePreviewPortal.tsx",
|
||||
"src/components/Collab/CollabNotesWebsiteThumbnail.tsx",
|
||||
"src/components/Collab/useCollabChatData.ts",
|
||||
"src/components/Collab/useCollabNotesData.ts",
|
||||
"src/components/Collab/useCollabPolls.ts",
|
||||
"src/components/Collections/CollectionPicker.tsx",
|
||||
"src/components/Collections/ImportFromTripModal.tsx",
|
||||
"src/components/Collections/useAddPlaceToCollection.ts",
|
||||
"src/components/Collections/useCollectionPlaceForm.ts",
|
||||
"src/components/Collections/useCollectionSharing.ts",
|
||||
"src/components/Collections/useListEditor.ts",
|
||||
"src/components/Collections/useSaveToCollection.ts",
|
||||
"src/components/Collections/useSaveTripPlacesToList.ts",
|
||||
"src/components/Collections/useTripCopyPicker.ts",
|
||||
"src/components/Dashboard/useCollectionLists.ts",
|
||||
"src/components/Dawarich/DawarichAtlasDialog.tsx",
|
||||
"src/components/Files/FileManagerAuthedImg.tsx",
|
||||
"src/components/Files/FileManagerImageLightbox.tsx",
|
||||
"src/components/Files/docsync/useConflicts.ts",
|
||||
"src/components/Files/docsync/useDocSync.ts",
|
||||
"src/components/Files/useFileManager.ts",
|
||||
"src/components/Files/useMediaLightbox.ts",
|
||||
"src/components/Journey/ContributorInviteDialog.tsx",
|
||||
"src/components/Journey/JourneyDetailPageEntryCard.tsx",
|
||||
"src/components/Journey/JourneyDetailPageEntryEditor.tsx",
|
||||
"src/components/Journey/JourneyDetailPageProviderPicker.tsx",
|
||||
"src/components/Journey/useConnectedPhotoProviders.ts",
|
||||
"src/components/Journey/useEntryPhotoOrder.ts",
|
||||
"src/components/Journey/useJourneyEntryForm.ts",
|
||||
"src/components/Journey/useJourneySettings.ts",
|
||||
"src/components/Journey/useJourneyShareLink.ts",
|
||||
"src/components/Journey/useJourneyTripSuggestion.ts",
|
||||
"src/components/Map/MapPluginLayers.tsx",
|
||||
"src/components/Map/MapPluginMarkers.tsx",
|
||||
"src/components/Map/MapView.tsx",
|
||||
"src/components/Map/MapViewGL.tsx",
|
||||
"src/components/Map/RouteCalculator.ts",
|
||||
"src/components/Map/routeUsageCounter.ts",
|
||||
"src/components/Map/usePoiExplore.ts",
|
||||
"src/components/OAuth/useScopeSelection.ts",
|
||||
"src/components/PDF/TripPDF.tsx",
|
||||
"src/components/Packing/ApplyTemplateButton.tsx",
|
||||
"src/components/Packing/usePackingBags.ts",
|
||||
"src/components/Packing/usePackingCategoryAssignees.ts",
|
||||
"src/components/Packing/usePackingExport.ts",
|
||||
"src/components/Packing/usePackingImport.ts",
|
||||
"src/components/Packing/usePackingListPanel.ts",
|
||||
"src/components/Packing/usePackingTemplates.ts",
|
||||
"src/components/Planner/AddressInput.tsx",
|
||||
"src/components/Planner/AirTrailImportModal.tsx",
|
||||
"src/components/Planner/AirportSelect.tsx",
|
||||
"src/components/Planner/BookingImportModal.tsx",
|
||||
"src/components/Planner/LocationSelect.tsx",
|
||||
"src/components/Planner/PlaceDetailsColumn.tsx",
|
||||
"src/components/Planner/PlaceInspector.tsx",
|
||||
"src/components/Planner/TransitSearchPanel.tsx",
|
||||
"src/components/Planner/TripWarningsBanner.tsx",
|
||||
"src/components/Planner/useDayDetail.ts",
|
||||
"src/components/Planner/useListImport.ts",
|
||||
"src/components/Planner/usePlaceSearch.ts",
|
||||
"src/components/Planner/usePlacesFileImport.ts",
|
||||
"src/components/Planner/usePlacesPool.ts",
|
||||
"src/components/Plugins/PluginContributions.tsx",
|
||||
"src/components/Plugins/PluginDaySchedule.tsx",
|
||||
"src/components/Plugins/PluginFrame.tsx",
|
||||
"src/components/Plugins/TripCardBadges.tsx",
|
||||
"src/components/Plugins/usePluginUserSettings.ts",
|
||||
"src/components/Roadtrip/useDayBoundaries.ts",
|
||||
"src/components/Roadtrip/useRefuelSearch.ts",
|
||||
"src/components/Roadtrip/useRoadtripVias.ts",
|
||||
"src/components/Settings/ApiKeysSection.tsx",
|
||||
"src/components/Settings/useAccountSettings.ts",
|
||||
"src/components/Settings/useAirTrailSettings.ts",
|
||||
"src/components/Settings/useMcpIntegration.ts",
|
||||
"src/components/Settings/useNotificationSettings.ts",
|
||||
"src/components/Settings/useOfflineSettings.ts",
|
||||
"src/components/Settings/usePasskeys.ts",
|
||||
"src/components/Settings/usePhotoProviderConnections.ts",
|
||||
"src/components/Settings/usePluginActivity.ts",
|
||||
"src/components/Studio/useBookPresence.ts",
|
||||
"src/components/Studio/useBookStore.ts",
|
||||
"src/components/Todo/useTodoList.ts",
|
||||
"src/components/Trips/TripFormModal.tsx",
|
||||
"src/components/Trips/useTripCoverPicker.ts",
|
||||
"src/components/Trips/useTripShare.ts",
|
||||
"src/components/Vacay/holidayRegions.ts",
|
||||
"src/components/Vacay/useVacayCalendarLogic.ts",
|
||||
"src/components/Vacay/useVacaySettings.ts",
|
||||
"src/components/Vacay/useVacayUserPicker.ts",
|
||||
"src/components/shared/PlaceAvatarUpload.tsx",
|
||||
"src/hooks/useAirtrailConnection.ts",
|
||||
"src/hooks/useDawarichConnection.ts",
|
||||
"src/hooks/useDawarichSuggestions.ts",
|
||||
"src/hooks/useInAppNotificationListener.ts",
|
||||
"src/hooks/usePlaceSuggestions.ts",
|
||||
"src/hooks/useRoadtripPreferencesSync.ts",
|
||||
"src/hooks/useTripWebSocket.ts",
|
||||
"src/hooks/useWebPush.ts",
|
||||
"src/mobile/screens/journey/MJourneyDetail.tsx",
|
||||
"src/mobile/screens/trip/plan/useMPlanTimeline.ts",
|
||||
"src/mobile/screens/trip/tabs/MCollabNotes.tsx",
|
||||
"src/pages/JourneyDetailPage.tsx",
|
||||
"src/pages/OAuthAuthorizePage.tsx",
|
||||
"src/pages/TripPlannerPage.tsx",
|
||||
"src/pages/admin/AdminSettingsTab.tsx",
|
||||
"src/pages/admin/useAdmin.ts",
|
||||
"src/pages/admin/useNotificationDefaults.ts",
|
||||
"src/pages/atlas/useAtlas.ts",
|
||||
"src/pages/atlas/useAtlasCountryActions.ts",
|
||||
"src/pages/collections/useCollections.ts",
|
||||
"src/pages/dashboard/useDashboard.ts",
|
||||
"src/pages/dashboard/useTripSearch.ts",
|
||||
"src/pages/forgotPassword/useForgotPassword.ts",
|
||||
"src/pages/help/useHelp.ts",
|
||||
"src/pages/join/useJoinTrip.ts",
|
||||
"src/pages/journey/useJourney.ts",
|
||||
"src/pages/journeyDetail/useJourneyDetail.ts",
|
||||
"src/pages/journeyDetail/useProviderPhotoAdds.ts",
|
||||
"src/pages/journeyPublic/useJourneyPublic.ts",
|
||||
"src/pages/journeyStudio/useJourneyStudio.ts",
|
||||
"src/pages/login/useLogin.ts",
|
||||
"src/pages/oauthAuthorize/useOAuthAuthorize.ts",
|
||||
"src/pages/resetPassword/useResetPassword.ts",
|
||||
"src/pages/settings/useSettings.ts",
|
||||
"src/pages/sharedTrip/useSharedTrip.ts",
|
||||
"src/pages/tripPlanner/useBookingEdits.ts",
|
||||
"src/pages/tripPlanner/usePlaceFormOpeners.ts",
|
||||
"src/pages/tripPlanner/usePlannerDialogs.ts",
|
||||
"src/pages/tripPlanner/useTripPlanner.ts",
|
||||
"src/pages/vacay/useVacay.ts"
|
||||
]
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* lint:offline: views go through the offline core, not around it.
|
||||
*
|
||||
* The data path is component, feature hook, store, repo, then api or Dexie,
|
||||
* and writes go through the mutation queue. A view that imports src/api/
|
||||
* skips the cache, the optimistic write and the idempotent replay, so the
|
||||
* feature breaks offline. Such files kept growing, by about fifteen a month.
|
||||
* The view files (components/, mobile/, pages/, hooks/) that already import
|
||||
* src/api/ for more than types are listed in scripts/offline-baseline.json;
|
||||
* no other view file may, and the list only shrinks.
|
||||
*
|
||||
* npm run lint:offline check against the baseline (CI)
|
||||
* npm run lint:offline -- --list print every view file that imports src/api/
|
||||
* npm run lint:offline -- --update take the files that stopped off the list;
|
||||
* it never adds one
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { check } from './lib/offline.mjs';
|
||||
import { runCli } from './lib/ratchet.mjs';
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
await runCli('lint:offline', (args) =>
|
||||
check({ root, update: args.includes('--update'), list: args.includes('--list') })
|
||||
);
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* lint:pairs: a desktop view and its phone twin run on the same logic.
|
||||
*
|
||||
* Most features have a desktop view under components/ or pages/ and a phone
|
||||
* view under mobile/, and each used to carry its own copy of the feature's
|
||||
* state, loading and handlers, so a fix on one side drifted away from the
|
||||
* other. The logic now lives in one shared hook per feature, and
|
||||
* scripts/feature-pairs.json names, for every feature, the hooks and the
|
||||
* desktop and phone files that run on them. The check fails when a listed
|
||||
* view stops importing one of its pair's hooks, when a listed file is gone,
|
||||
* when a hook sits in a view layer, and when a file under src/mobile/screens
|
||||
* is in no pair and not under mobileOnly with the reason it has no twin. A
|
||||
* new phone screen is so either wired to the desktop feature's hook or marked
|
||||
* as phone only on purpose, never a quiet second copy. lint:dup catches the
|
||||
* copies that look alike; this catches the ones written anew.
|
||||
*
|
||||
* npm run lint:pairs check the manifest against the tree (CI)
|
||||
* npm run lint:pairs -- --list print every pair with its hooks
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { check } from './lib/pairs.mjs';
|
||||
import { runCli } from './lib/ratchet.mjs';
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
await runCli('lint:pairs', (args) => check({ root, list: args.includes('--list') }));
|
||||
@@ -1,113 +0,0 @@
|
||||
{
|
||||
"components/Admin/AdminPluginsPanel.tsx": 2,
|
||||
"components/Budget/BudgetPanelMemberChips.tsx": 6,
|
||||
"components/Budget/CostsPanel.tsx": 3,
|
||||
"components/Budget/ReceiptPreviewModal.tsx": 2,
|
||||
"components/Collab/CollabChatEmojiPicker.tsx": 3,
|
||||
"components/Collab/CollabChatReactionBadge.tsx": 3,
|
||||
"components/Collab/CollabChatReactionMenu.tsx": 1,
|
||||
"components/Collab/CollabPolls.tsx": 1,
|
||||
"components/Collections/CollectionPlaceDetail.tsx": 2,
|
||||
"components/Files/FileManagerAvatarChip.tsx": 3,
|
||||
"components/Journey/JourneyDayJump.tsx": 1,
|
||||
"components/Journey/JourneyDetailPageDatePicker.tsx": 1,
|
||||
"components/Journey/JourneyDetailPageEntryEditor.tsx": 1,
|
||||
"components/Journey/JourneyDetailPageGalleryView.tsx": 1,
|
||||
"components/Journey/JourneyDetailPagePhotoGrid.tsx": 1,
|
||||
"components/Journey/JourneyDetailPageProviderPicker.tsx": 1,
|
||||
"components/Journey/JourneyEntryCover.tsx": 1,
|
||||
"components/Journey/JourneyMap.tsx": 1,
|
||||
"components/Journey/JourneyMapGL.tsx": 2,
|
||||
"components/Journey/MobileEntryView.tsx": 1,
|
||||
"components/Journey/PhotoLightbox.tsx": 3,
|
||||
"components/Journey/journeyPhotoLayer.ts": 1,
|
||||
"components/Layout/Navbar.tsx": 1,
|
||||
"components/Layout/OfflineBanner.tsx": 1,
|
||||
"components/Map/DawarichTrailPill.tsx": 1,
|
||||
"components/Map/LocationButton.tsx": 1,
|
||||
"components/Map/MapTogglePill.tsx": 1,
|
||||
"components/Map/MapView.tsx": 6,
|
||||
"components/Map/MapViewGL.tsx": 8,
|
||||
"components/Map/NightPauseTooltip.tsx": 1,
|
||||
"components/Map/PlaceHoverCard.tsx": 1,
|
||||
"components/Map/PoiCategoryPill.tsx": 1,
|
||||
"components/Map/hazardPopup.css": 2,
|
||||
"components/Map/hazardPopup.ts": 1,
|
||||
"components/Map/locationMarkerMapbox.ts": 2,
|
||||
"components/Map/nightPauseMarker.ts": 4,
|
||||
"components/Map/ratingBadge.ts": 1,
|
||||
"components/PDF/TripPDF.tsx": 8,
|
||||
"components/PDF/tripMapImage.ts": 1,
|
||||
"components/Packing/PackingListPanelBagCard.tsx": 1,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 3,
|
||||
"components/Packing/PackingListPanelItemRow.tsx": 2,
|
||||
"components/Packing/PackingShareControl.tsx": 3,
|
||||
"components/Planner/DayDetailPanel.tsx": 1,
|
||||
"components/Planner/PlaceInspector.tsx": 1,
|
||||
"components/Planner/TransitSearchPanel.tsx": 3,
|
||||
"components/Planner/bookings/BookingsTimeline.tsx": 24,
|
||||
"components/Roadtrip/AnchoredPopover.tsx": 2,
|
||||
"components/Settings/IntegrationsTab.tsx": 2,
|
||||
"components/Settings/PluginSettingsTab.tsx": 2,
|
||||
"components/Settings/ToggleSwitch.tsx": 1,
|
||||
"components/Studio/BookSheetsView.tsx": 3,
|
||||
"components/Studio/PageNumbers.tsx": 1,
|
||||
"components/Studio/PeerCursors.tsx": 1,
|
||||
"components/Studio/SpreadView.tsx": 8,
|
||||
"components/Studio/StudioCanvas.tsx": 22,
|
||||
"components/Studio/StudioElementsPanel.tsx": 1,
|
||||
"components/Studio/StudioExport.tsx": 1,
|
||||
"components/Studio/StudioInspector.tsx": 1,
|
||||
"components/Studio/StudioSavedLayouts.tsx": 1,
|
||||
"components/Studio/StudioSidebar.tsx": 3,
|
||||
"components/Studio/TravelElements.tsx": 4,
|
||||
"components/Studio/TravelPreview.tsx": 1,
|
||||
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
|
||||
"components/SystemNotices/releaseNotice.css": 2,
|
||||
"components/Vacay/VacayCalendar.tsx": 3,
|
||||
"components/Vacay/VacayMonthCard.tsx": 7,
|
||||
"components/Vacay/VacaySettings.tsx": 1,
|
||||
"components/shared/ContextMenu.tsx": 3,
|
||||
"components/shared/CustomDateTimePicker.tsx": 2,
|
||||
"components/shared/CustomSelect.tsx": 4,
|
||||
"components/shared/CustomTimePicker.tsx": 3,
|
||||
"components/shared/DetailShell.tsx": 1,
|
||||
"components/shared/ElevationProfile.tsx": 2,
|
||||
"components/shared/NavigationMenu.tsx": 2,
|
||||
"components/shared/PlaceAvatarUpload.tsx": 3,
|
||||
"components/shared/PublicLanguagePicker.tsx": 1,
|
||||
"components/shared/SlidingTabs.tsx": 3,
|
||||
"components/shared/StarRating.tsx": 3,
|
||||
"components/shared/Toast.tsx": 1,
|
||||
"components/shared/Tooltip.tsx": 2,
|
||||
"components/shared/dialogParts.tsx": 1,
|
||||
"help/contexts/tripMap.ts": 1,
|
||||
"hooks/useAnchoredPosition.ts": 3,
|
||||
"hooks/useElementRect.ts": 3,
|
||||
"mobile/components/MSegmented.tsx": 3,
|
||||
"mobile/components/MSheet.tsx": 1,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 1,
|
||||
"mobile/screens/journey/MJourneyDetail.tsx": 3,
|
||||
"mobile/screens/journey/MJourneyEntrySheet.tsx": 1,
|
||||
"mobile/screens/trip/MTripShell.tsx": 3,
|
||||
"mobile/screens/trip/map/MMapArea.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MCollabPolls.tsx": 1,
|
||||
"mobile/screens/trip/tabs/MFileLightbox.tsx": 2,
|
||||
"mobile/screens/vacay/MVacay.tsx": 1,
|
||||
"mobile/screens/vacay/MVacayMonth.tsx": 1,
|
||||
"pages/AtlasPage.tsx": 2,
|
||||
"pages/JourneyDetailPage.tsx": 1,
|
||||
"pages/JourneyPublicPage.tsx": 3,
|
||||
"pages/LoginPage.tsx": 23,
|
||||
"pages/TripPlannerPage.tsx": 11,
|
||||
"pages/VacayPage.tsx": 1,
|
||||
"pages/admin/AdminNotificationsTab.tsx": 1,
|
||||
"pages/admin/AdminSettingsTab.tsx": 1,
|
||||
"pages/atlas/AtlasLayerToggle.tsx": 1,
|
||||
"pages/sharedTrip/SharedMap.tsx": 4,
|
||||
"styles/collections.css": 1,
|
||||
"styles/studio.css": 7,
|
||||
"utils/formatters.ts": 1,
|
||||
"utils/mapViewport.ts": 4,
|
||||
"utils/touchDragBridge.ts": 1
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"pages/tripPlanner/PlanMapStage.tsx": 4,
|
||||
"mobile/components/trek3d/MascotClip.tsx": 1
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* lint:rtl: keeps the layout following the reading direction.
|
||||
*
|
||||
* Arabic runs the app with dir="rtl". A physical class or style (ml-2,
|
||||
* text-left, paddingLeft, left: 0) stays on the same side in either direction,
|
||||
* so a gap, an indent or an alignment that follows the text in English lands
|
||||
* on the wrong side in Arabic. The logical forms (ms-2, text-start,
|
||||
* paddingInlineStart, insetInlineStart) follow the direction, and in a
|
||||
* left-to-right language they are exactly the physical ones.
|
||||
*
|
||||
* Some things are physical on purpose: a map, a chart axis, a drag handle, a
|
||||
* popover placed at measured coordinates. Those stay. Every file's physical
|
||||
* uses are counted against scripts/rtl-baseline.json, and the check fails when
|
||||
* a file holds more than its entry (a file without an entry holds none), so
|
||||
* new code reaches for the logical forms. A line that is physical on purpose
|
||||
* carries `rtl-lint-disable` in a comment (in a string it is no marker). The
|
||||
* marked lines are counted too, against scripts/rtl-disable-baseline.json, so
|
||||
* the marker cannot quietly take the place of the logical forms: a new one
|
||||
* raises its file's entry by hand, where review sees it. An entry of either
|
||||
* baseline above what its file holds now, or for a file that is gone, fails
|
||||
* as well until --update lowers it.
|
||||
*
|
||||
* npm run lint:rtl check against the baselines (CI)
|
||||
* npm run lint:rtl -- --list print every counted use, file by file
|
||||
* npm run lint:rtl -- --update lower both baselines to what the files hold now;
|
||||
* it never raises an entry
|
||||
*
|
||||
* The matching lives in scripts/lib/rtl.mjs.
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runCli } from './lib/ratchet.mjs';
|
||||
import { check } from './lib/rtl.mjs';
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
await runCli('lint:rtl', (args) => check({ root, update: args.includes('--update'), list: args.includes('--list') }));
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"e2e/help/fixtures.ts": 2291,
|
||||
"src/api/client.ts": 1916,
|
||||
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2764,
|
||||
"src/components/Budget/CostsPanel.tsx": 1816,
|
||||
"src/components/Map/MapView.tsx": 1461,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
||||
"src/components/Map/MapViewGL.tsx": 2425,
|
||||
"src/components/Map/RouteCalculator.ts": 1195,
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5380,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2978,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2864,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1250,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1304,
|
||||
"src/components/Studio/TravelElements.tsx": 1738,
|
||||
"src/components/Studio/autoLayout.ts": 1691,
|
||||
"src/components/Studio/bookTemplates.data.ts": 1309,
|
||||
"src/components/Studio/countryShapes.ts": 2595,
|
||||
"src/index.css": 1636,
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4030,
|
||||
"src/pages/LoginPage.tsx": 1348,
|
||||
"src/pages/TripPlannerPage.tsx": 1168,
|
||||
"src/pages/TripPlannerPage.wiring.test.tsx": 2041,
|
||||
"src/pages/atlas/useAtlas.ts": 1287,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
|
||||
"src/styles/studio.css": 2087
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* lint:size: keeps files from growing into the next god file.
|
||||
*
|
||||
* A file past a thousand lines holds several concerns that no longer fit in
|
||||
* one reading, and every change to one of them risks the others. Each source
|
||||
* file and stylesheet under src/ may hold 1000 lines, each test (under tests/,
|
||||
* a Playwright spec or fixture under e2e/, or a co-located *.test.*) 2000. Lines are counted as an editor wraps them
|
||||
* at 120 columns, so joining lines does not make a file smaller. The files
|
||||
* that were already larger are listed in scripts/size-baseline.json with the
|
||||
* size they had, and the check fails when one of them grows past its entry. A
|
||||
* file that needs to grow is split by concern instead (the trip planner hook,
|
||||
* page and road trip sidebar are the precedent). An entry above the file's
|
||||
* size now, or for a file that is gone, fails as well until --update lowers
|
||||
* it: otherwise the file could grow back unseen, and a new file at a deleted
|
||||
* path would inherit its allowance.
|
||||
*
|
||||
* npm run lint:size check against the baseline (CI)
|
||||
* npm run lint:size -- --update lower the baseline to what the files hold now;
|
||||
* it never raises an entry, and drops a file
|
||||
* that is back under its limit
|
||||
*
|
||||
* The measuring lives in scripts/lib/size.mjs.
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runCli } from './lib/ratchet.mjs';
|
||||
import { check } from './lib/size.mjs';
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
await runCli('lint:size', (args) => check({ root, update: args.includes('--update') }));
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"src/pages/JourneyDetailPage.test.tsx": 35
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* lint:skips: no test is focused, and skipped tests may only go away.
|
||||
*
|
||||
* A stray .only passes CI while the rest of its file never runs, so it fails
|
||||
* this check wherever it appears. A skipped or todo test is coverage that was
|
||||
* switched off and then forgotten (JourneyDetailPage.test.tsx has carried 35
|
||||
* skipped blocks since April); the files that hold some are listed in
|
||||
* scripts/skip-baseline.json with their count, and a file may hold no more.
|
||||
* An entry above what its file holds now, or for a file that is gone, fails
|
||||
* as well until --update lowers it.
|
||||
* Playwright's test.fixme counts as a skip. Only a skip that decides at run
|
||||
* time is left out: skipIf and runIf, and in a Playwright spec
|
||||
* test.skip(condition, 'why'), unless the condition is a literal that always
|
||||
* skips (skipIf(true), runIf(false), test.skip(true, 'why')).
|
||||
* Covers the vitest tests under src/ and tests/ and the Playwright specs
|
||||
* under e2e/.
|
||||
*
|
||||
* npm run lint:skips check against the baseline (CI)
|
||||
* npm run lint:skips -- --update lower the baseline to what the files hold now;
|
||||
* it never raises an entry
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runCli } from './lib/ratchet.mjs';
|
||||
import { check } from './lib/skips.mjs';
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
await runCli('lint:skips', (args) => check({ root, update: args.includes('--update') }));
|
||||
@@ -1,167 +0,0 @@
|
||||
{
|
||||
"src/components/Admin/AddonManager.tsx": 2,
|
||||
"src/components/Admin/AdminPluginsPanel.test.tsx": 2,
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 3,
|
||||
"src/components/Admin/storage/useStorageAdmin.test.tsx": 1,
|
||||
"src/components/Admin/useBackupAdmin.ts": 1,
|
||||
"src/components/Budget/BudgetPanelMemberChips.tsx": 1,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 1,
|
||||
"src/components/Collab/CollabChat.helpers.ts": 1,
|
||||
"src/components/Collab/CollabChat.tsx": 3,
|
||||
"src/components/Collab/CollabChatEmojiPicker.tsx": 5,
|
||||
"src/components/Collab/CollabChatMessageText.tsx": 2,
|
||||
"src/components/Collab/CollabChatMessages.tsx": 4,
|
||||
"src/components/Collab/CollabChatReactionBadge.tsx": 1,
|
||||
"src/components/Collab/CollabChatReactionMenu.tsx": 2,
|
||||
"src/components/Collab/CollabChatTwemojiImg.tsx": 1,
|
||||
"src/components/Collab/CollabNotes.helpers.ts": 3,
|
||||
"src/components/Collab/CollabNotes.test.tsx": 2,
|
||||
"src/components/Collab/CollabNotes.tsx": 23,
|
||||
"src/components/Collab/CollabNotesCard.tsx": 1,
|
||||
"src/components/Collab/CollabNotesCategorySettingsModal.tsx": 6,
|
||||
"src/components/Collab/CollabNotesEditableCatName.tsx": 2,
|
||||
"src/components/Collab/CollabNotesFormModal.tsx": 8,
|
||||
"src/components/Collab/CollabPanel.tsx": 6,
|
||||
"src/components/Collab/CollabPolls.test.tsx": 1,
|
||||
"src/components/Collab/CollabPolls.tsx": 4,
|
||||
"src/components/Collab/WhatsNextWidget.test.tsx": 16,
|
||||
"src/components/Collab/WhatsNextWidget.tsx": 5,
|
||||
"src/components/Collab/useCollabChat.ts": 6,
|
||||
"src/components/Files/FileManager.tsx": 2,
|
||||
"src/components/Files/FileManagerAssignModal.tsx": 2,
|
||||
"src/components/Files/FileManagerMarkdownPreviewModal.tsx": 4,
|
||||
"src/components/Files/FileManagerPdfPreviewModal.tsx": 5,
|
||||
"src/components/Files/FileManagerPreviewModals.test.tsx": 3,
|
||||
"src/components/Files/useFileManager.ts": 5,
|
||||
"src/components/Journey/JourneyDayDawarich.test.tsx": 1,
|
||||
"src/components/Journey/JourneyDetailPageSettingsDialog.tsx": 1,
|
||||
"src/components/Journey/JourneyMap.test.tsx": 6,
|
||||
"src/components/Journey/journeyGeo.ts": 4,
|
||||
"src/components/Layout/Navbar.test.tsx": 1,
|
||||
"src/components/Map/MapView.test.tsx": 2,
|
||||
"src/components/Map/MapView.tsx": 8,
|
||||
"src/components/Map/MapViewGL.tsx": 1,
|
||||
"src/components/Map/ReservationOverlay.test.tsx": 1,
|
||||
"src/components/Map/transitGeometry.ts": 1,
|
||||
"src/components/Map/useDawarichTrailGL.test.ts": 4,
|
||||
"src/components/Map/useMarkerThumbs.ts": 1,
|
||||
"src/components/PDF/TripPDF.tsx": 40,
|
||||
"src/components/Packing/PackingListPanelItemRow.test.tsx": 2,
|
||||
"src/components/Planner/DayPlanSidebar.constants.ts": 1,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 61,
|
||||
"src/components/Planner/DayPlanSidebarNoteModal.test.tsx": 1,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 3,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1,
|
||||
"src/components/Planner/PlaceInspector.tsx": 17,
|
||||
"src/components/Planner/PlacesBulkCategoryModal.test.tsx": 1,
|
||||
"src/components/Planner/PlacesSidebar.tsx": 1,
|
||||
"src/components/Planner/PlacesSidebarMobileDayPicker.tsx": 8,
|
||||
"src/components/Planner/ReservationModal.tsx": 4,
|
||||
"src/components/Planner/TransitSearchPanel.tsx": 2,
|
||||
"src/components/Planner/assignmentOptions.ts": 1,
|
||||
"src/components/Planner/bookings/bookingFacts.ts": 1,
|
||||
"src/components/Planner/bookings/bookingsModel.ts": 2,
|
||||
"src/components/Planner/bookings/timelineModel.ts": 4,
|
||||
"src/components/Planner/reservationFormModel.test.ts": 1,
|
||||
"src/components/Planner/transportEndpoints.ts": 2,
|
||||
"src/components/Planner/useDayDetail.ts": 2,
|
||||
"src/components/Planner/usePlacesSidebar.ts": 1,
|
||||
"src/components/Planner/useTripExport.ts": 1,
|
||||
"src/components/Plugins/PluginFrame.tsx": 2,
|
||||
"src/components/Roadtrip/RoadtripAlternativesBar.tsx": 1,
|
||||
"src/components/Roadtrip/RoadtripCorridorPanel.tsx": 4,
|
||||
"src/components/Roadtrip/RoadtripSidebarBadges.tsx": 4,
|
||||
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 2,
|
||||
"src/components/Roadtrip/RoadtripSidebarHeaders.tsx": 1,
|
||||
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 5,
|
||||
"src/components/Roadtrip/RoadtripTrackModal.tsx": 4,
|
||||
"src/components/Roadtrip/ServiceStopSection.tsx": 2,
|
||||
"src/components/Roadtrip/useRoadtripRoutes.test.ts": 2,
|
||||
"src/components/Roadtrip/useRoadtripRoutes.ts": 4,
|
||||
"src/components/Settings/DisplaySettingsTab.tsx": 2,
|
||||
"src/components/Settings/MapboxPreview.tsx": 1,
|
||||
"src/components/Settings/NotificationsTab.test.tsx": 1,
|
||||
"src/components/Settings/PasskeysSection.tsx": 1,
|
||||
"src/components/Settings/PhotoProvidersSection.test.tsx": 2,
|
||||
"src/components/Studio/StudioCanvas.tsx": 1,
|
||||
"src/components/Studio/StudioInspector.tsx": 1,
|
||||
"src/components/Studio/autoLayout.ts": 1,
|
||||
"src/components/Todo/todoListModel.ts": 1,
|
||||
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
|
||||
"src/components/Tours/planner/useTourPlanner.ts": 2,
|
||||
"src/components/Tours/tourParts.tsx": 11,
|
||||
"src/components/Trips/TripFormModal.tsx": 3,
|
||||
"src/components/Trips/TripMembersModal.tsx": 1,
|
||||
"src/components/Vacay/VacayCalendar.tsx": 1,
|
||||
"src/components/Vacay/VacayPersons.tsx": 4,
|
||||
"src/components/Vacay/VacaySettings.tsx": 2,
|
||||
"src/components/Vacay/VacaySharedCalendars.tsx": 1,
|
||||
"src/components/Vacay/holidayRegions.test.ts": 2,
|
||||
"src/components/Vacay/holidayRegions.ts": 4,
|
||||
"src/components/Weather/WeatherWidget.tsx": 14,
|
||||
"src/components/shared/CustomSelect.test.tsx": 1,
|
||||
"src/components/shared/PlaceAvatar.test.tsx": 2,
|
||||
"src/components/shared/PlaceAvatar.tsx": 1,
|
||||
"src/components/shared/PlaceAvatarUpload.test.tsx": 1,
|
||||
"src/hooks/useDawarichSuggestions.test.ts": 1,
|
||||
"src/mobile/screens/admin/MAdminAddonManager.tsx": 2,
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 6,
|
||||
"src/mobile/screens/journey/MJourneySettingsSheet.tsx": 1,
|
||||
"src/mobile/screens/settings/MSettingsAccount.tsx": 1,
|
||||
"src/mobile/screens/trip/MTripShell.tsx": 1,
|
||||
"src/mobile/screens/trip/map/MMapArea.tsx": 1,
|
||||
"src/mobile/screens/trip/places/MPlacesBrowser.tsx": 2,
|
||||
"src/mobile/screens/trip/places/MToursSelectionList.tsx": 6,
|
||||
"src/mobile/screens/trip/plan/planTimelineModel.ts": 1,
|
||||
"src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 2,
|
||||
"src/mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 1,
|
||||
"src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": 1,
|
||||
"src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": 2,
|
||||
"src/mobile/screens/trip/sheets/MReservationSheet.tsx": 1,
|
||||
"src/mobile/screens/trip/sheets/MTripSheets.tsx": 1,
|
||||
"src/mobile/screens/trip/tabs/transportsModel.ts": 1,
|
||||
"src/mobile/screens/vacay/MVacaySettingsSheet.tsx": 1,
|
||||
"src/pages/AtlasPage.tsx": 6,
|
||||
"src/pages/FilesPage.tsx": 1,
|
||||
"src/pages/JourneyDetailPage.tsx": 1,
|
||||
"src/pages/JourneyPublicPage.test.tsx": 1,
|
||||
"src/pages/LoginPage.tsx": 1,
|
||||
"src/pages/VacayPage.tsx": 1,
|
||||
"src/pages/admin/useAdmin.ts": 4,
|
||||
"src/pages/atlas/atlasModel.ts": 2,
|
||||
"src/pages/atlas/useAtlas.ts": 9,
|
||||
"src/pages/collections/collectionsModel.ts": 1,
|
||||
"src/pages/journeyDetail/useJourneyDetail.ts": 3,
|
||||
"src/pages/sharedTrip/SharedPlanView.tsx": 1,
|
||||
"src/pages/tripPlanner/PlanMapStage.tsx": 1,
|
||||
"src/pages/tripPlanner/b4a-wiring.test.ts": 1,
|
||||
"src/pages/tripPlanner/useBookingEdits.ts": 1,
|
||||
"src/pages/tripPlanner/useDayPlanEdits.ts": 2,
|
||||
"src/pages/tripPlanner/useMapPlaces.ts": 3,
|
||||
"src/pages/tripPlanner/usePlaceEdits.ts": 2,
|
||||
"src/pages/tripPlanner/usePlaceFormOpeners.test.ts": 2,
|
||||
"src/pages/tripPlanner/usePlaceFormOpeners.ts": 1,
|
||||
"src/pages/tripPlanner/useTripPlanner.ts": 2,
|
||||
"src/repo/tripRepo.ts": 2,
|
||||
"src/store/slices/budgetSlice.test.ts": 1,
|
||||
"src/utils/formatters.test.ts": 1,
|
||||
"src/utils/touchDragPolyfill.ts": 1,
|
||||
"tests/integration/api/client.test.ts": 1,
|
||||
"tests/integration/hooks/useRouteCalculation.test.ts": 1,
|
||||
"tests/unit/i18n/i18nKeysScript.test.ts": 3,
|
||||
"tests/unit/mobile/admin/MAdminAddonManager.test.tsx": 2,
|
||||
"tests/unit/mobile/admin/MAdminGitHubPanel.test.tsx": 1,
|
||||
"tests/unit/mobile/admin/MAdminNotifyMatrix.test.tsx": 1,
|
||||
"tests/unit/mobile/admin/MAdminStoragePanel.test.tsx": 1,
|
||||
"tests/unit/mobile/dashboard/MDashWidgets.test.tsx": 1,
|
||||
"tests/unit/mobile/settings/MSettingsNotifications.test.tsx": 3,
|
||||
"tests/unit/mobile/settings/MSettingsPlugins.test.tsx": 2,
|
||||
"tests/unit/mobile/trip/MCostsTab.test.tsx": 1,
|
||||
"tests/unit/mobile/trip/filesModel.test.ts": 3,
|
||||
"tests/unit/remoteEventHandler/budget.test.ts": 1,
|
||||
"tests/unit/remoteEventHandler/trip.test.ts": 1,
|
||||
"tests/unit/scripts/eslint-ratchet.test.ts": 1,
|
||||
"tests/unit/slices/budgetSlice.test.ts": 1,
|
||||
"tests/unit/studio/autoLayout.travel.test.ts": 3,
|
||||
"tests/unit/studio/mapFraming.test.tsx": 1
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* lint:strict: the errors strict null checks would raise may only go down.
|
||||
*
|
||||
* The client compiles with strict: false, so a `.nullable()` field from
|
||||
* @trek/shared arrives as if it were always there and an untyped parameter is
|
||||
* an `any` nobody sees. Turning strict on at once would mean fixing hundreds
|
||||
* of files in one go. Instead tsconfig.strict.json is the same program with
|
||||
* strictNullChecks and noImplicitAny on, and this counts its errors per file
|
||||
* against scripts/strict-baseline.json: a file may not gain one, and a file
|
||||
* without an entry, which every new file is, has to be clean. An entry above
|
||||
* its count, or for a file that is gone, fails until --update lowers it, so a
|
||||
* file brought down stays down. When a whole directory reaches zero, it can
|
||||
* move into the main tsconfig's strict settings for good.
|
||||
*
|
||||
* npm run lint:strict check against the baseline (CI)
|
||||
* npm run lint:strict -- --list print every error, file by file
|
||||
* npm run lint:strict -- --update lower the baseline to today's counts;
|
||||
* it never raises an entry
|
||||
*
|
||||
* The parsing and the comparison live in scripts/lib/strict.mjs.
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runCli } from './lib/ratchet.mjs';
|
||||
import { check } from './lib/strict.mjs';
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
await runCli('lint:strict', (args) =>
|
||||
check({ root, update: args.includes('--update'), list: args.includes('--list') })
|
||||
);
|
||||
@@ -1,323 +0,0 @@
|
||||
{
|
||||
"App.tsx": 11,
|
||||
"components/Admin/AdminPluginsPanel.tsx": 2,
|
||||
"components/Admin/DefaultUserSettingsTab.tsx": 1,
|
||||
"components/Atlas/AtlasCountryPlaces.tsx": 6,
|
||||
"components/BackgroundTasks/BackgroundTasksWidget.tsx": 4,
|
||||
"components/Budget/BudgetPanel.helpers.ts": 2,
|
||||
"components/Budget/BudgetPanelMemberChips.tsx": 4,
|
||||
"components/Budget/CostsPanel.tsx": 37,
|
||||
"components/Budget/ReceiptPreviewModal.tsx": 6,
|
||||
"components/Budget/ReceiptScanModal.tsx": 2,
|
||||
"components/Budget/costsCategories.tsx": 14,
|
||||
"components/Collab/CollabChat.tsx": 1,
|
||||
"components/Collab/CollabChatAttachment.tsx": 4,
|
||||
"components/Collab/CollabChatEmojiPicker.tsx": 1,
|
||||
"components/Collab/CollabChatReactionBadge.tsx": 1,
|
||||
"components/Collab/CollabChatReactionMenu.tsx": 1,
|
||||
"components/Collab/CollabLinks.tsx": 1,
|
||||
"components/Collab/CollabNotesFilePreviewPortal.tsx": 2,
|
||||
"components/Collab/CollabPolls.tsx": 3,
|
||||
"components/Collections/CollectionHero.tsx": 1,
|
||||
"components/Collections/MSaveToCollectionSheet.tsx": 12,
|
||||
"components/Collections/StatusBadge.tsx": 4,
|
||||
"components/Dawarich/DawarichAtlasDialog.tsx": 3,
|
||||
"components/Files/FileManagerAvatarChip.tsx": 1,
|
||||
"components/Files/FileManagerImageLightbox.tsx": 8,
|
||||
"components/Journey/ContributorInviteDialog.tsx": 76,
|
||||
"components/Journey/JournalBody.tsx": 1,
|
||||
"components/Journey/JourneyDayDawarich.tsx": 2,
|
||||
"components/Journey/JourneyDayJump.tsx": 6,
|
||||
"components/Journey/JourneyDayScrubber.tsx": 3,
|
||||
"components/Journey/JourneyDetailPageChips.tsx": 2,
|
||||
"components/Journey/JourneyDetailPageDatePicker.tsx": 43,
|
||||
"components/Journey/JourneyDetailPageEntryCard.tsx": 73,
|
||||
"components/Journey/JourneyDetailPageEntryEditor.tsx": 2,
|
||||
"components/Journey/JourneyDetailPageExpandableStory.tsx": 17,
|
||||
"components/Journey/JourneyDetailPageGalleryView.tsx": 15,
|
||||
"components/Journey/JourneyDetailPageMapView.tsx": 50,
|
||||
"components/Journey/JourneyDetailPagePhotoGrid.tsx": 6,
|
||||
"components/Journey/JourneyDetailPageProviderPicker.tsx": 163,
|
||||
"components/Journey/JourneyDetailPageScrollTrigger.tsx": 4,
|
||||
"components/Journey/JourneyDetailPageVerdictSection.tsx": 51,
|
||||
"components/Journey/JourneyEntryCover.tsx": 15,
|
||||
"components/Journey/JourneyMap.tsx": 3,
|
||||
"components/Journey/JourneyMapGL.tsx": 5,
|
||||
"components/Journey/JourneyShareSection.tsx": 41,
|
||||
"components/Journey/MobileEntryView.tsx": 94,
|
||||
"components/Journey/MobileMapTimeline.tsx": 8,
|
||||
"components/Journey/PhotoLightbox.tsx": 13,
|
||||
"components/Journey/VideoPlayer.tsx": 2,
|
||||
"components/Journey/VideoPlayerLazy.tsx": 1,
|
||||
"components/Journey/journeyMapPopup.ts": 17,
|
||||
"components/Journey/moodConfig.ts": 5,
|
||||
"components/Layout/BottomNav.tsx": 3,
|
||||
"components/Layout/DemoBanner.tsx": 25,
|
||||
"components/Layout/InAppNotificationBell.tsx": 10,
|
||||
"components/Layout/MobileTopBar.tsx": 51,
|
||||
"components/Layout/Navbar.tsx": 23,
|
||||
"components/Layout/NewVersionNotice.tsx": 3,
|
||||
"components/Layout/OfflineBanner.tsx": 2,
|
||||
"components/Layout/PageSidebar.tsx": 3,
|
||||
"components/Map/LocationButton.tsx": 1,
|
||||
"components/Map/MapPluginMarkers.tsx": 2,
|
||||
"components/Map/MapTogglePill.tsx": 1,
|
||||
"components/Map/MapView.tsx": 10,
|
||||
"components/Map/MapViewGL.tsx": 11,
|
||||
"components/Map/NightPauseTooltip.tsx": 1,
|
||||
"components/Map/PlaceHoverCard.tsx": 1,
|
||||
"components/Map/ReservationOverlay.tsx": 5,
|
||||
"components/Map/RoadtripViaMarkers.tsx": 1,
|
||||
"components/Map/locationMarkerMapbox.ts": 2,
|
||||
"components/Map/placePopup.ts": 6,
|
||||
"components/Map/poiCategories.ts": 7,
|
||||
"components/Map/ratingBadge.ts": 2,
|
||||
"components/Map/reservationsMapbox.ts": 1,
|
||||
"components/Notifications/InAppNotificationItem.tsx": 8,
|
||||
"components/PDF/TripPDF.tsx": 53,
|
||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||
"components/Packing/PackingListPanelHeader.tsx": 5,
|
||||
"components/Packing/PackingListPanelItemRow.tsx": 2,
|
||||
"components/Packing/PackingShareControl.tsx": 3,
|
||||
"components/Packing/packingPrintDocument.ts": 24,
|
||||
"components/Planner/AirTrailImportModal.tsx": 4,
|
||||
"components/Planner/BookingCostsSection.tsx": 1,
|
||||
"components/Planner/BookingImportModal.tsx": 4,
|
||||
"components/Planner/DayDetailPanel.tsx": 1,
|
||||
"components/Planner/IcsSubscribeModal.tsx": 10,
|
||||
"components/Planner/PlaceInspector.tsx": 1,
|
||||
"components/Planner/PlacesSidebarMobileDayPicker.tsx": 3,
|
||||
"components/Planner/SubscribeLinks.tsx": 5,
|
||||
"components/Planner/SuggestionDropdown.tsx": 1,
|
||||
"components/Planner/TransitJourneyModal.tsx": 4,
|
||||
"components/Planner/TransitSearchPanel.tsx": 2,
|
||||
"components/Planner/TravelerPicker.tsx": 3,
|
||||
"components/Planner/bookings/BookingsTimeline.tsx": 1,
|
||||
"components/Plugins/PluginContributions.tsx": 5,
|
||||
"components/Plugins/PluginWidgets.tsx": 1,
|
||||
"components/Roadtrip/AnchoredPopover.tsx": 1,
|
||||
"components/Roadtrip/ServiceStopSection.tsx": 1,
|
||||
"components/Roadtrip/serviceMarker.ts": 1,
|
||||
"components/Settings/useAppearanceEditor.ts": 5,
|
||||
"components/Studio/BookSheetsView.tsx": 2,
|
||||
"components/Studio/SpreadView.tsx": 3,
|
||||
"components/Studio/StudioElementsPanel.tsx": 8,
|
||||
"components/Studio/StudioInspector.tsx": 2,
|
||||
"components/Studio/StudioShell.tsx": 2,
|
||||
"components/Studio/StudioSidebar.tsx": 2,
|
||||
"components/Studio/StudioTravelPanel.tsx": 1,
|
||||
"components/Studio/TravelElements.tsx": 5,
|
||||
"components/Studio/printSheets.ts": 5,
|
||||
"components/Studio/templates.ts": 3,
|
||||
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
|
||||
"components/SystemNotices/SystemNoticeBanner.tsx": 28,
|
||||
"components/SystemNotices/SystemNoticeModal.tsx": 107,
|
||||
"components/Todo/TodoListPanel.tsx": 6,
|
||||
"components/Todo/todoListModel.ts": 3,
|
||||
"components/Trips/TripFormModal.tsx": 9,
|
||||
"components/Trips/TripMembersModal.tsx": 6,
|
||||
"components/Vacay/VacayBadge.tsx": 3,
|
||||
"components/Vacay/VacayCalendar.tsx": 20,
|
||||
"components/Vacay/VacayMonthCard.tsx": 5,
|
||||
"components/Vacay/VacayPersons.tsx": 4,
|
||||
"components/Vacay/VacaySettings.tsx": 27,
|
||||
"components/Vacay/VacaySharedCalendars.tsx": 6,
|
||||
"components/Vacay/VacayStats.tsx": 14,
|
||||
"components/Weather/WeatherWidget.tsx": 4,
|
||||
"components/shared/ConfirmDialog.tsx": 8,
|
||||
"components/shared/ContextMenu.tsx": 1,
|
||||
"components/shared/CopyTripDialog.tsx": 12,
|
||||
"components/shared/CustomDateTimePicker.tsx": 1,
|
||||
"components/shared/CustomSelect.tsx": 1,
|
||||
"components/shared/CustomTimePicker.tsx": 1,
|
||||
"components/shared/EmptyState.tsx": 1,
|
||||
"components/shared/ErrorBoundary.tsx": 2,
|
||||
"components/shared/Modal.tsx": 3,
|
||||
"components/shared/NavigationMenu.tsx": 1,
|
||||
"components/shared/NoteColorPicker.tsx": 2,
|
||||
"components/shared/PlaceAvatarUpload.tsx": 3,
|
||||
"components/shared/PublicLanguagePicker.tsx": 3,
|
||||
"components/shared/Skeleton.tsx": 2,
|
||||
"components/shared/Spinner.tsx": 2,
|
||||
"components/shared/StarRating.tsx": 1,
|
||||
"components/shared/Toast.tsx": 8,
|
||||
"components/shared/Tooltip.tsx": 1,
|
||||
"components/shared/TrackColorPicker.tsx": 2,
|
||||
"components/shared/TripLoadingSplash.tsx": 10,
|
||||
"mobile/components/MBadge.tsx": 2,
|
||||
"mobile/components/MBottomNav.tsx": 2,
|
||||
"mobile/components/MChip.tsx": 2,
|
||||
"mobile/components/MDayImpactList.tsx": 4,
|
||||
"mobile/components/MListRow.tsx": 1,
|
||||
"mobile/components/MSegmented.tsx": 2,
|
||||
"mobile/components/MSheet.tsx": 1,
|
||||
"mobile/components/MStorageTestResult.tsx": 3,
|
||||
"mobile/components/MToastHost.tsx": 2,
|
||||
"mobile/components/MUserPickerList.tsx": 1,
|
||||
"mobile/screens/admin/MAdmin.tsx": 9,
|
||||
"mobile/screens/admin/MAdminAddonManager.tsx": 26,
|
||||
"mobile/screens/admin/MAdminAuditLogPanel.tsx": 15,
|
||||
"mobile/screens/admin/MAdminBackupPanel.tsx": 8,
|
||||
"mobile/screens/admin/MAdminCategoryManager.tsx": 8,
|
||||
"mobile/screens/admin/MAdminDefaultUserSettings.tsx": 5,
|
||||
"mobile/screens/admin/MAdminDevNotificationsPanel.tsx": 6,
|
||||
"mobile/screens/admin/MAdminGitHubPanel.tsx": 30,
|
||||
"mobile/screens/admin/MAdminMcpTokensPanel.tsx": 14,
|
||||
"mobile/screens/admin/MAdminNotifyMatrix.tsx": 5,
|
||||
"mobile/screens/admin/MAdminPackingTemplateManager.tsx": 8,
|
||||
"mobile/screens/admin/MAdminPermissionsPanel.tsx": 7,
|
||||
"mobile/screens/admin/MAdminPluginsPanel.tsx": 129,
|
||||
"mobile/screens/admin/MAdminSettingsSection.tsx": 10,
|
||||
"mobile/screens/admin/MAdminSheets.tsx": 11,
|
||||
"mobile/screens/admin/MAdminStoragePanel.tsx": 42,
|
||||
"mobile/screens/admin/MAdminUi.tsx": 10,
|
||||
"mobile/screens/admin/MAdminUsersSection.tsx": 14,
|
||||
"mobile/screens/admin/MApiProviderBlocks.tsx": 10,
|
||||
"mobile/screens/atlas/MAtlas.tsx": 7,
|
||||
"mobile/screens/atlas/MAtlasBucketSheet.tsx": 16,
|
||||
"mobile/screens/atlas/MAtlasCountryPopup.tsx": 11,
|
||||
"mobile/screens/atlas/MAtlasSearch.tsx": 5,
|
||||
"mobile/screens/atlas/MAtlasStatsCard.tsx": 3,
|
||||
"mobile/screens/collections/MCollAddSheet.tsx": 7,
|
||||
"mobile/screens/collections/MCollCategoryPicker.tsx": 2,
|
||||
"mobile/screens/collections/MCollEditSheet.tsx": 5,
|
||||
"mobile/screens/collections/MCollLabelsSheet.tsx": 6,
|
||||
"mobile/screens/collections/MCollLinksEditor.tsx": 2,
|
||||
"mobile/screens/collections/MCollListPickerSheet.tsx": 3,
|
||||
"mobile/screens/collections/MCollPlaceRow.tsx": 3,
|
||||
"mobile/screens/collections/MCollPlaceSheet.tsx": 11,
|
||||
"mobile/screens/collections/MCollShareSheet.tsx": 14,
|
||||
"mobile/screens/collections/MCollSheetKit.tsx": 6,
|
||||
"mobile/screens/collections/MCollTripPickerSheet.tsx": 4,
|
||||
"mobile/screens/collections/MCollections.tsx": 21,
|
||||
"mobile/screens/dashboard/MDashWidgets.tsx": 22,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 36,
|
||||
"mobile/screens/dashboard/MNewTripSheet.tsx": 21,
|
||||
"mobile/screens/dashboard/MUserMenu.tsx": 7,
|
||||
"mobile/screens/journey/MJourney.tsx": 10,
|
||||
"mobile/screens/journey/MJourneyCreateSheet.tsx": 10,
|
||||
"mobile/screens/journey/MJourneyDetail.tsx": 6,
|
||||
"mobile/screens/journey/MJourneyEntrySheet.tsx": 52,
|
||||
"mobile/screens/journey/MJourneySettingsSheet.tsx": 32,
|
||||
"mobile/screens/journey/mobileJourneyMeta.ts": 4,
|
||||
"mobile/screens/notifications/MNotificationRow.tsx": 4,
|
||||
"mobile/screens/notifications/MNotifications.tsx": 3,
|
||||
"mobile/screens/settings/MAirTrailConnectionSection.tsx": 2,
|
||||
"mobile/screens/settings/MConfirmSheet.tsx": 2,
|
||||
"mobile/screens/settings/MDawarichConnectionSection.tsx": 5,
|
||||
"mobile/screens/settings/MMobileDashOrder.tsx": 2,
|
||||
"mobile/screens/settings/MMobileNavCustomizer.tsx": 8,
|
||||
"mobile/screens/settings/MPhotoProvidersSection.tsx": 2,
|
||||
"mobile/screens/settings/MScopeGroupPicker.tsx": 5,
|
||||
"mobile/screens/settings/MSetPickerSheet.tsx": 2,
|
||||
"mobile/screens/settings/MSettings.tsx": 2,
|
||||
"mobile/screens/settings/MSettingsAbout.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsAccount.tsx": 21,
|
||||
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
|
||||
"mobile/screens/settings/MSettingsMap.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsMcp.tsx": 30,
|
||||
"mobile/screens/settings/MSettingsNotifications.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsOffline.tsx": 18,
|
||||
"mobile/screens/settings/MSettingsPlugins.tsx": 14,
|
||||
"mobile/screens/settings/MSettingsUi.tsx": 11,
|
||||
"mobile/screens/settings/MWebPushCard.tsx": 1,
|
||||
"mobile/screens/trip/MTripLoadingSplash.tsx": 2,
|
||||
"mobile/screens/trip/MTripShell.tsx": 9,
|
||||
"mobile/screens/trip/places/MPlacesBrowser.tsx": 10,
|
||||
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 4,
|
||||
"mobile/screens/trip/places/MPlacesFilterControls.tsx": 1,
|
||||
"mobile/screens/trip/places/MPlacesFilterSheet.tsx": 3,
|
||||
"mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx": 7,
|
||||
"mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": 1,
|
||||
"mobile/screens/trip/places/MToursSelectionList.tsx": 9,
|
||||
"mobile/screens/trip/plan/MPlanTimeline.tsx": 14,
|
||||
"mobile/screens/trip/plan/MPlanTimelineRows.tsx": 26,
|
||||
"mobile/screens/trip/roadtrip/MRoadtripRows.tsx": 31,
|
||||
"mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 17,
|
||||
"mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 8,
|
||||
"mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": 2,
|
||||
"mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": 7,
|
||||
"mobile/screens/trip/roadtrip/MRtDraftSheet.tsx": 5,
|
||||
"mobile/screens/trip/roadtrip/MRtInfoSheet.tsx": 9,
|
||||
"mobile/screens/trip/roadtrip/MRtKindSheet.tsx": 3,
|
||||
"mobile/screens/trip/roadtrip/MRtStaySheet.tsx": 17,
|
||||
"mobile/screens/trip/roadtrip/MRtStopSheet.tsx": 12,
|
||||
"mobile/screens/trip/sheets/ImpFileStep.tsx": 12,
|
||||
"mobile/screens/trip/sheets/ImpListStep.tsx": 3,
|
||||
"mobile/screens/trip/sheets/MAccommodationSheet.tsx": 10,
|
||||
"mobile/screens/trip/sheets/MBookingFields.tsx": 6,
|
||||
"mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": 5,
|
||||
"mobile/screens/trip/sheets/MCostSheet.tsx": 33,
|
||||
"mobile/screens/trip/sheets/MDaySheet.tsx": 22,
|
||||
"mobile/screens/trip/sheets/MDaysSheet.tsx": 4,
|
||||
"mobile/screens/trip/sheets/MExportSheet.tsx": 2,
|
||||
"mobile/screens/trip/sheets/MImportSheet.tsx": 2,
|
||||
"mobile/screens/trip/sheets/MLinkedCosts.tsx": 9,
|
||||
"mobile/screens/trip/sheets/MMehrSheet.tsx": 3,
|
||||
"mobile/screens/trip/sheets/MNoteSheet.tsx": 1,
|
||||
"mobile/screens/trip/sheets/MPlaceEditSheet.tsx": 2,
|
||||
"mobile/screens/trip/sheets/MPlaceSheet.tsx": 27,
|
||||
"mobile/screens/trip/sheets/MReservationSheet.tsx": 2,
|
||||
"mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9,
|
||||
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
|
||||
"mobile/screens/trip/sheets/MTripSheetUi.tsx": 5,
|
||||
"mobile/screens/trip/sheets/MTripSheets.tsx": 2,
|
||||
"mobile/screens/trip/sheets/PlCategoryPicker.tsx": 2,
|
||||
"mobile/screens/trip/sheets/PlFileAttach.tsx": 7,
|
||||
"mobile/screens/trip/sheets/PlPlaceSearch.tsx": 6,
|
||||
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 7,
|
||||
"mobile/screens/trip/sheets/PlTimeFields.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MBagsSheet.tsx": 13,
|
||||
"mobile/screens/trip/tabs/MBookingsTab.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MCollabChat.tsx": 22,
|
||||
"mobile/screens/trip/tabs/MCollabNotes.tsx": 19,
|
||||
"mobile/screens/trip/tabs/MCollabPolls.tsx": 18,
|
||||
"mobile/screens/trip/tabs/MCollabTab.tsx": 1,
|
||||
"mobile/screens/trip/tabs/MCostsTab.tsx": 71,
|
||||
"mobile/screens/trip/tabs/MDocSyncSheet.tsx": 43,
|
||||
"mobile/screens/trip/tabs/MFileLightbox.tsx": 12,
|
||||
"mobile/screens/trip/tabs/MFileLinkSheet.tsx": 2,
|
||||
"mobile/screens/trip/tabs/MFileMenuSheet.tsx": 1,
|
||||
"mobile/screens/trip/tabs/MFileTrashSheet.tsx": 4,
|
||||
"mobile/screens/trip/tabs/MFilesTab.tsx": 12,
|
||||
"mobile/screens/trip/tabs/MPackItemSheet.tsx": 7,
|
||||
"mobile/screens/trip/tabs/MPackingImportSheet.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MPackingListTab.tsx": 32,
|
||||
"mobile/screens/trip/tabs/MReservationCardParts.tsx": 6,
|
||||
"mobile/screens/trip/tabs/MTaskSheet.tsx": 6,
|
||||
"mobile/screens/trip/tabs/MTodoListTab.tsx": 15,
|
||||
"mobile/screens/trip/tabs/MTransportsTab.tsx": 2,
|
||||
"mobile/screens/trip/tabs/filesModel.ts": 5,
|
||||
"mobile/screens/trip/tabs/tabChrome.tsx": 12,
|
||||
"mobile/screens/vacay/MVacay.tsx": 23,
|
||||
"mobile/screens/vacay/MVacayInviteSheet.tsx": 9,
|
||||
"mobile/screens/vacay/MVacayMonth.tsx": 3,
|
||||
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 36,
|
||||
"mobile/screens/vacay/MVacayShareSheet.tsx": 11,
|
||||
"mobile/screens/vacay/vacayDayModel.ts": 1,
|
||||
"pages/AtlasPage.tsx": 32,
|
||||
"pages/DashboardPage.tsx": 1,
|
||||
"pages/FilesPage.tsx": 11,
|
||||
"pages/ForgotPasswordPage.tsx": 18,
|
||||
"pages/HelpPage.tsx": 21,
|
||||
"pages/InAppNotificationsPage.tsx": 11,
|
||||
"pages/JoinTripPage.tsx": 4,
|
||||
"pages/JourneyDetailPage.tsx": 53,
|
||||
"pages/JourneyPage.tsx": 64,
|
||||
"pages/JourneyPublicPage.tsx": 113,
|
||||
"pages/LoginPage.tsx": 58,
|
||||
"pages/OAuthAuthorizePage.tsx": 33,
|
||||
"pages/PluginPage.tsx": 1,
|
||||
"pages/ResetPasswordPage.tsx": 23,
|
||||
"pages/TripPlannerPage.tsx": 7,
|
||||
"pages/VacayPage.tsx": 13,
|
||||
"pages/atlas/useAtlas.ts": 2,
|
||||
"pages/collections/collectionsModel.ts": 1,
|
||||
"pages/collections/useCollections.ts": 1,
|
||||
"pages/sharedTrip/SharedChrome.tsx": 2,
|
||||
"pages/sharedTrip/SharedMap.tsx": 1,
|
||||
"pages/tripPlanner/ListsContainer.tsx": 1,
|
||||
"pages/tripPlanner/PlanMapStage.tsx": 1,
|
||||
"utils/touchDragBridge.ts": 1
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
{
|
||||
"components/Admin/GitHubPanel.tsx": 3,
|
||||
"components/Admin/PluginPoiCategoryList.tsx": 1,
|
||||
"components/Admin/useCategoryAdmin.ts": 1,
|
||||
"components/Budget/CostsPanel.tsx": 1,
|
||||
"components/Budget/CostsToolbar.tsx": 1,
|
||||
"components/Collab/CollabNotesFilePreviewPortal.tsx": 3,
|
||||
"components/Collections/AddPlaceToCollectionModal.tsx": 1,
|
||||
"components/Collections/BulkAssignLabelModal.tsx": 1,
|
||||
"components/Collections/CollectionPicker.tsx": 1,
|
||||
"components/Collections/ImportCollectionModal.tsx": 1,
|
||||
"components/Collections/MoveToListModal.tsx": 1,
|
||||
"components/Collections/SaveTripPlacesToListModal.tsx": 1,
|
||||
"components/Dawarich/DawarichAtlasSidePanel.tsx": 1,
|
||||
"components/Files/FileManagerImageLightbox.tsx": 1,
|
||||
"components/Files/FileManagerRow.tsx": 3,
|
||||
"components/Files/FileManagerToolbar.tsx": 1,
|
||||
"components/Help/HelpNav.tsx": 1,
|
||||
"components/Journey/journeyPhotoLayer.ts": 2,
|
||||
"components/Map/DawarichTrailLayer.tsx": 1,
|
||||
"components/Map/dawarichTrail.ts": 10,
|
||||
"components/Map/useDawarichTrailGL.ts": 1,
|
||||
"components/Packing/PackingListPanelItemRow.tsx": 1,
|
||||
"components/Planner/DayReorderPopup.tsx": 1,
|
||||
"components/Planner/bookings/bookingsModel.ts": 1,
|
||||
"components/Planner/bookings/transitParts.tsx": 1,
|
||||
"components/Planner/usePlaceForm.ts": 1,
|
||||
"components/Roadtrip/RangeStrip.tsx": 1,
|
||||
"components/Roadtrip/RoadtripAlternativesBar.tsx": 1,
|
||||
"components/Roadtrip/RoadtripCategoryPicker.tsx": 2,
|
||||
"components/Roadtrip/RoadtripCorridorPanel.tsx": 3,
|
||||
"components/Roadtrip/RoadtripSidebarStopRows.tsx": 4,
|
||||
"components/Roadtrip/RoadtripTrackModal.tsx": 1,
|
||||
"components/Roadtrip/StopKindPicker.tsx": 2,
|
||||
"components/Roadtrip/dayColors.ts": 8,
|
||||
"components/Roadtrip/nightBookend.ts": 1,
|
||||
"components/Roadtrip/stopKinds.ts": 1,
|
||||
"components/Settings/AboutTab.tsx": 3,
|
||||
"components/Settings/AccountTab.tsx": 2,
|
||||
"components/Settings/AppearanceSettingsTab.tsx": 1,
|
||||
"components/Settings/PasskeysSection.tsx": 1,
|
||||
"components/Studio/BookSheetsView.tsx": 1,
|
||||
"components/Studio/SpreadView.tsx": 2,
|
||||
"components/Studio/StudioElementsPanel.tsx": 2,
|
||||
"components/Studio/StudioSwatches.tsx": 1,
|
||||
"components/Studio/TravelElements.tsx": 6,
|
||||
"components/Studio/autoLayout.ts": 50,
|
||||
"components/Studio/peerColour.ts": 1,
|
||||
"components/Studio/printSheets.ts": 4,
|
||||
"components/Trips/TripMemberAvatar.tsx": 2,
|
||||
"components/Vacay/VacayPersons.tsx": 1,
|
||||
"components/shared/DialogShell.tsx": 2,
|
||||
"components/shared/PasswordChecklist.tsx": 2,
|
||||
"mobile/components/MSheet.tsx": 1,
|
||||
"mobile/screens/atlas/MAtlas.tsx": 1,
|
||||
"mobile/screens/atlas/MAtlasCountryPopup.tsx": 2,
|
||||
"mobile/screens/atlas/MAtlasSearch.tsx": 1,
|
||||
"mobile/screens/collections/MCollEditSheet.tsx": 2,
|
||||
"mobile/screens/collections/MCollPlaceSheet.tsx": 2,
|
||||
"mobile/screens/collections/MCollShareSheet.tsx": 1,
|
||||
"mobile/screens/collections/collectionsMobileModel.ts": 15,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 3,
|
||||
"mobile/screens/journey/MJourney.tsx": 2,
|
||||
"mobile/screens/trip/roadtrip/MRoadtripRows.tsx": 2,
|
||||
"mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 1,
|
||||
"mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": 1,
|
||||
"mobile/screens/trip/roadtrip/MRtKindSheet.tsx": 1,
|
||||
"mobile/screens/trip/roadtrip/MRtStopSheet.tsx": 1,
|
||||
"mobile/screens/trip/sheets/MCostSheet.tsx": 1,
|
||||
"pages/AtlasPage.tsx": 1,
|
||||
"pages/admin/AdminUserModals.tsx": 1,
|
||||
"pages/sharedTrip/SharedChrome.tsx": 4,
|
||||
"pages/sharedTrip/SharedMap.tsx": 2
|
||||
}
|
||||
@@ -1,42 +1,73 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* theme:lint: styling that bypasses the appearance tokens may only go down.
|
||||
* theme:lint — guards the appearance token system.
|
||||
*
|
||||
* A user picks a colour scheme, an accent, transparency and a text size, and
|
||||
* applyAppearance() turns that into CSS variables. A colour literal, a palette
|
||||
* class, a raw text size or a z-index of its own never hears about any of it,
|
||||
* so the surface stays light in a dark scheme, keeps indigo under a red
|
||||
* accent, or ignores the text size setting. The contract is in
|
||||
* src/theme/README.md; this counts what of it source shows, per file under
|
||||
* src/ (tests excluded):
|
||||
* 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(..)])
|
||||
*
|
||||
* arbitrary-color bg-[#..], text-[rgba(..)] and the like
|
||||
* inline-color color: '#111', background: 'rgba(...)' in a style
|
||||
* inline-font-size fontSize: 13
|
||||
* palette-class bg-gray-100, text-indigo-600, bg-white, text-black
|
||||
* raw-text-size text-xs, text-sm ... text-9xl instead of the type tiers
|
||||
* z-index-literal z-[9999], zIndex: 10000 above Tailwind's z-50, instead of
|
||||
* a step of the layering scale (--z-bar ... --z-toast)
|
||||
* dark-mode-read reading settings.dark_mode instead of the .dark class
|
||||
* ALLOWED (never flagged): var(--token) inline styles, bg-[var(--..)] classes,
|
||||
* and genuinely dynamic values (data-driven colors, computed sizes/positions).
|
||||
*
|
||||
* Each file may hold no more hits than its entry in scripts/theme-baseline.json
|
||||
* (a file without an entry holds none). A line that is literal on purpose (map
|
||||
* paint, PDF, brand colours) carries `theme-lint-disable` in a comment; those
|
||||
* lines are counted against scripts/theme-disable-baseline.json, so the marker
|
||||
* cannot quietly take the place of the tokens. An entry of either baseline
|
||||
* above what its file holds now, or for a file that is gone, fails as well
|
||||
* until --update lowers it.
|
||||
*
|
||||
* npm run theme:lint check against the baselines (CI)
|
||||
* npm run theme:lint -- --list print every hit, file by file
|
||||
* npm run theme:lint -- --update lower both baselines to what the files hold now;
|
||||
* it never raises an entry
|
||||
*
|
||||
* The matching lives in scripts/lib/theme.mjs.
|
||||
* 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 { fileURLToPath } from 'node:url';
|
||||
import { runCli } from './lib/ratchet.mjs';
|
||||
import { check } from './lib/theme.mjs';
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
await runCli('theme:lint', (args) => check({ root, update: args.includes('--update'), list: args.includes('--list') }));
|
||||
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/, /packingPrintDocument/, /\.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);
|
||||
}
|
||||
|
||||
+350
-365
File diff suppressed because it is too large
Load Diff
+341
-217
@@ -1,67 +1,72 @@
|
||||
import React, { useEffect, useState, useRef, ReactNode, Suspense } from 'react'
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router'
|
||||
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 React, { ReactNode, Suspense, useEffect, useRef, useState } from 'react';
|
||||
import { Navigate, Route, Routes, useLocation } from 'react-router';
|
||||
import { useAddonStore } from './store/addonStore';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { usePluginStore } from './store/pluginStore';
|
||||
import { useSettingsStore } from './store/settingsStore';
|
||||
import { applyAppearance } from './theme/applyAppearance';
|
||||
// The one page that stays in the entry chunk. Anyone logged out lands here, and
|
||||
// every other route redirects here first — a chunk round trip in front of the login
|
||||
// form would slow down the single screen that has to be there immediately.
|
||||
import LoginPage from './pages/LoginPage'
|
||||
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 MRouteFallback from './mobile/components/MRouteFallback'
|
||||
import ErrorBoundary from './components/shared/ErrorBoundary'
|
||||
import { lazyWithRetry } from './utils/lazyWithRetry'
|
||||
import { reconcileAppVersion } from './utils/versionHandover'
|
||||
import { useServerVersionStore } from './store/serverVersionStore'
|
||||
import { useIsPhone } from './mobile/useIsPhone'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import { authApi, isAuthPublicPath } from './api/client'
|
||||
import { tripRepo } from './repo/tripRepo'
|
||||
import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination'
|
||||
import { takeResumeRoute, useRememberRoute } from './utils/resumeRoute'
|
||||
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
|
||||
import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync'
|
||||
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
|
||||
import OfflineBanner from './components/Layout/OfflineBanner'
|
||||
import NewVersionNotice from './components/Layout/NewVersionNotice'
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||
import HelpPanel from './components/Help/HelpPanel'
|
||||
import { authApi, isAuthPublicPath } from './api/client';
|
||||
import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget';
|
||||
import MSaveToCollectionSheet from './components/Collections/MSaveToCollectionSheet';
|
||||
import SaveToCollectionModal from './components/Collections/SaveToCollectionModal';
|
||||
import HelpPanel from './components/Help/HelpPanel';
|
||||
import OfflineBanner from './components/Layout/OfflineBanner';
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js';
|
||||
import ErrorBoundary from './components/shared/ErrorBoundary';
|
||||
import { ToastContainer } from './components/shared/Toast';
|
||||
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts';
|
||||
import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync';
|
||||
import { TranslationProvider, useTranslation } from './i18n';
|
||||
import MobileShell from './mobile/MobileShell';
|
||||
import MRouteFallback from './mobile/components/MRouteFallback';
|
||||
import { useIsPhone } from './mobile/useIsPhone';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import { tripRepo } from './repo/tripRepo';
|
||||
import { PermissionLevel, usePermissionsStore } from './store/permissionsStore';
|
||||
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers';
|
||||
import { lazyWithRetry } from './utils/lazyWithRetry';
|
||||
import { takeResumeRoute, useRememberRoute } from './utils/resumeRoute';
|
||||
import {
|
||||
DEFAULT_START_PAGE,
|
||||
DEFAULT_START_TRIP_TAB,
|
||||
readStartDestination,
|
||||
SETTINGS_WAIT_MS,
|
||||
START_DESTINATION_ROUTE,
|
||||
tripStartPath,
|
||||
} from './utils/startDestination';
|
||||
import { reconcileAppVersion } from './utils/versionHandover';
|
||||
// Notice action registrations (side-effect imports):
|
||||
import './pages/Trips/noticeActions.js'
|
||||
import { managedRoutes } from './managed'
|
||||
import { managedRoutes } from './managed';
|
||||
import './pages/Trips/noticeActions.js';
|
||||
|
||||
// Every page below loads on demand. The entry chunk used to carry all twenty of
|
||||
// them eagerly, so opening /dashboard also paid for the planner, the journal, the
|
||||
// atlas and the vacation planner. lazyWithRetry rather than lazy: a chunk that
|
||||
// fails once gets a second, cache-busted attempt before the route boundary reaches
|
||||
// for a reload.
|
||||
const PluginPage = lazyWithRetry(() => import('./pages/PluginPage'))
|
||||
const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage'))
|
||||
const ResetPasswordPage = lazyWithRetry(() => import('./pages/ResetPasswordPage'))
|
||||
const DashboardPage = lazyWithRetry(() => import('./pages/DashboardPage'))
|
||||
const TripPlannerPage = lazyWithRetry(() => import('./pages/TripPlannerPage'))
|
||||
const FilesPage = lazyWithRetry(() => import('./pages/FilesPage'))
|
||||
const AdminPage = lazyWithRetry(() => import('./pages/AdminPage'))
|
||||
const SettingsPage = lazyWithRetry(() => import('./pages/SettingsPage'))
|
||||
const VacayPage = lazyWithRetry(() => import('./pages/VacayPage'))
|
||||
const HelpPage = lazyWithRetry(() => import('./pages/HelpPage'))
|
||||
const AtlasPage = lazyWithRetry(() => import('./pages/AtlasPage'))
|
||||
const JourneyPage = lazyWithRetry(() => import('./pages/JourneyPage'))
|
||||
const JourneyDetailPage = lazyWithRetry(() => import('./pages/JourneyDetailPage'))
|
||||
const JourneyStudioPage = lazyWithRetry(() => import('./pages/JourneyStudioPage'))
|
||||
const CollectionsPage = lazyWithRetry(() => import('./pages/CollectionsPage'))
|
||||
const JourneyPublicPage = lazyWithRetry(() => import('./pages/JourneyPublicPage'))
|
||||
const SharedTripPage = lazyWithRetry(() => import('./pages/SharedTripPage'))
|
||||
const JoinTripPage = lazyWithRetry(() => import('./pages/JoinTripPage'))
|
||||
const InAppNotificationsPage = lazyWithRetry(() => import('./pages/InAppNotificationsPage.tsx'))
|
||||
const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePage'))
|
||||
const PluginPage = lazyWithRetry(() => import('./pages/PluginPage'));
|
||||
const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage'));
|
||||
const ResetPasswordPage = lazyWithRetry(() => import('./pages/ResetPasswordPage'));
|
||||
const DashboardPage = lazyWithRetry(() => import('./pages/DashboardPage'));
|
||||
const TripPlannerPage = lazyWithRetry(() => import('./pages/TripPlannerPage'));
|
||||
const FilesPage = lazyWithRetry(() => import('./pages/FilesPage'));
|
||||
const AdminPage = lazyWithRetry(() => import('./pages/AdminPage'));
|
||||
const SettingsPage = lazyWithRetry(() => import('./pages/SettingsPage'));
|
||||
const VacayPage = lazyWithRetry(() => import('./pages/VacayPage'));
|
||||
const HelpPage = lazyWithRetry(() => import('./pages/HelpPage'));
|
||||
const AtlasPage = lazyWithRetry(() => import('./pages/AtlasPage'));
|
||||
const JourneyPage = lazyWithRetry(() => import('./pages/JourneyPage'));
|
||||
const JourneyDetailPage = lazyWithRetry(() => import('./pages/JourneyDetailPage'));
|
||||
const JourneyStudioPage = lazyWithRetry(() => import('./pages/JourneyStudioPage'));
|
||||
const CollectionsPage = lazyWithRetry(() => import('./pages/CollectionsPage'));
|
||||
const JourneyPublicPage = lazyWithRetry(() => import('./pages/JourneyPublicPage'));
|
||||
const SharedTripPage = lazyWithRetry(() => import('./pages/SharedTripPage'));
|
||||
const JoinTripPage = lazyWithRetry(() => import('./pages/JoinTripPage'));
|
||||
const InAppNotificationsPage = lazyWithRetry(() => import('./pages/InAppNotificationsPage.tsx'));
|
||||
const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePage'));
|
||||
|
||||
// The ten phone screens are chunks of their own, alongside the desktop pages
|
||||
// rather than inside them. Each page used to import its M screen statically and
|
||||
@@ -69,69 +74,64 @@ const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePag
|
||||
// viewport only picked which half stayed dark. Here the branch decides the
|
||||
// chunk: a phone never loads the desktop planner, a desktop never the mobile
|
||||
// shell.
|
||||
const MDashboardScreen = lazyWithRetry(() => import('./mobile/screens/dashboard/MDashboard'))
|
||||
const MTripScreen = lazyWithRetry(() => import('./mobile/screens/trip/MTripShell'))
|
||||
const MAdminScreen = lazyWithRetry(() => import('./mobile/screens/admin/MAdmin'))
|
||||
const MSettingsScreen = lazyWithRetry(() => import('./mobile/screens/settings/MSettings'))
|
||||
const MVacayScreen = lazyWithRetry(() => import('./mobile/screens/vacay/MVacay'))
|
||||
const MAtlasScreen = lazyWithRetry(() => import('./mobile/screens/atlas/MAtlas'))
|
||||
const MJourneyScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourney'))
|
||||
const MJourneyDetailScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourneyDetail'))
|
||||
const MCollectionsScreen = lazyWithRetry(() => import('./mobile/screens/collections/MCollections'))
|
||||
const MNotificationsScreen = lazyWithRetry(() => import('./mobile/screens/notifications/MNotifications'))
|
||||
const MDashboardScreen = lazyWithRetry(() => import('./mobile/screens/dashboard/MDashboard'));
|
||||
const MTripScreen = lazyWithRetry(() => import('./mobile/screens/trip/MTripShell'));
|
||||
const MAdminScreen = lazyWithRetry(() => import('./mobile/screens/admin/MAdmin'));
|
||||
const MSettingsScreen = lazyWithRetry(() => import('./mobile/screens/settings/MSettings'));
|
||||
const MVacayScreen = lazyWithRetry(() => import('./mobile/screens/vacay/MVacay'));
|
||||
const MAtlasScreen = lazyWithRetry(() => import('./mobile/screens/atlas/MAtlas'));
|
||||
const MJourneyScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourney'));
|
||||
const MJourneyDetailScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourneyDetail'));
|
||||
const MCollectionsScreen = lazyWithRetry(() => import('./mobile/screens/collections/MCollections'));
|
||||
const MNotificationsScreen = lazyWithRetry(() => import('./mobile/screens/notifications/MNotifications'));
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
adminRequired?: boolean
|
||||
addonId?: string
|
||||
children: ReactNode;
|
||||
adminRequired?: boolean;
|
||||
addonId?: string;
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedRouteProps) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const isLoading = useAuthStore((s) => s.isLoading)
|
||||
const appRequireMfa = useAuthStore((s) => s.appRequireMfa)
|
||||
const loggingOut = useAuthStore((s) => s.loggingOut)
|
||||
const addonStore = useAddonStore()
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const isPhone = useIsPhone()
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const appRequireMfa = useAuthStore((s) => s.appRequireMfa);
|
||||
const loggingOut = useAuthStore((s) => s.loggingOut);
|
||||
const addonStore = useAddonStore();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const isPhone = useIsPhone();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
|
||||
<p className="text-slate-500 text-sm">{t('common.loading')}</p>
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-slate-900"></div>
|
||||
<p className="text-sm text-slate-500">{t('common.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// A session that ended on its own should come back to where it left off; a
|
||||
// deliberate sign-out is a fresh start and gets no return ticket, so the
|
||||
// startup destination decides where the next login lands.
|
||||
if (loggingOut) return <Navigate to="/login" replace state={{ noRedirect: true }} />
|
||||
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash)
|
||||
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
||||
if (loggingOut) return <Navigate to="/login" replace state={{ noRedirect: true }} />;
|
||||
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash);
|
||||
return <Navigate to={`/login?redirect=${redirectParam}`} replace />;
|
||||
}
|
||||
|
||||
if (
|
||||
appRequireMfa &&
|
||||
user &&
|
||||
!user.mfa_enabled &&
|
||||
location.pathname !== '/settings'
|
||||
) {
|
||||
return <Navigate to="/settings?mfa=required" replace />
|
||||
if (appRequireMfa && user && !user.mfa_enabled && location.pathname !== '/settings') {
|
||||
return <Navigate to="/settings?mfa=required" replace />;
|
||||
}
|
||||
|
||||
if (adminRequired && user && user.role !== 'admin') {
|
||||
return <Navigate to="/dashboard" replace />
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
if (addonId && addonStore.loaded && !addonStore.isEnabled(addonId)) {
|
||||
return <Navigate to="/dashboard" replace />
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
// Below the md breakpoint the new mobile shell owns chrome (tokens, dock,
|
||||
@@ -148,16 +148,11 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
|
||||
return (
|
||||
<MobileShell isPhone={isPhone}>
|
||||
<RouteMemory />
|
||||
<ErrorBoundary
|
||||
key={location.pathname}
|
||||
boundaryId="route"
|
||||
level="route"
|
||||
variant={isPhone ? 'mobile' : 'desktop'}
|
||||
>
|
||||
<ErrorBoundary key={location.pathname} boundaryId="route" level="route" variant={isPhone ? 'mobile' : 'desktop'}>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</MobileShell>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,7 +161,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
|
||||
* failed, and the two anonymous share pages.
|
||||
*/
|
||||
function PublicRoute({ children, redirectAuthed = false }: { children: React.ReactNode; redirectAuthed?: boolean }) {
|
||||
const location = useLocation()
|
||||
const location = useLocation();
|
||||
// redirectAuthed (only /login and /register) bounces a visitor who is already
|
||||
// authenticated when they land here — manual URL, browser back button (#1810).
|
||||
// Only on a bare URL, because every parameter this page takes is a flow that
|
||||
@@ -180,15 +175,15 @@ function PublicRoute({ children, redirectAuthed = false }: { children: React.Rea
|
||||
// useLogin navigates away.
|
||||
// The target is START_DESTINATION_ROUTE, not /dashboard, so the bounce honours
|
||||
// the start-page preference the same way useLogin does.
|
||||
const wasAuthenticated = useRef(useAuthStore.getState().isAuthenticated)
|
||||
const wasAuthenticated = useRef(useAuthStore.getState().isAuthenticated);
|
||||
if (redirectAuthed && wasAuthenticated.current && !location.search) {
|
||||
return <Navigate to={START_DESTINATION_ROUTE} replace />
|
||||
return <Navigate to={START_DESTINATION_ROUTE} replace />;
|
||||
}
|
||||
return (
|
||||
<ErrorBoundary key={location.pathname} boundaryId="public-route" level="route">
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,12 +192,15 @@ function PublicRoute({ children, redirectAuthed = false }: { children: React.Rea
|
||||
* point: the ternary that used to sit in each page only ran once the browser
|
||||
* had already paid for both trees.
|
||||
*/
|
||||
function ViewportRoute({ phone: Phone, desktop: Desktop }: {
|
||||
phone: React.ComponentType
|
||||
desktop: React.ComponentType
|
||||
function ViewportRoute({
|
||||
phone: Phone,
|
||||
desktop: Desktop,
|
||||
}: {
|
||||
phone: React.ComponentType;
|
||||
desktop: React.ComponentType;
|
||||
}): React.ReactElement {
|
||||
const isPhone = useIsPhone()
|
||||
return isPhone ? <Phone /> : <Desktop />
|
||||
const isPhone = useIsPhone();
|
||||
return isPhone ? <Phone /> : <Desktop />;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,65 +218,78 @@ function ViewportRoute({ phone: Phone, desktop: Desktop }: {
|
||||
*/
|
||||
/** Remembers the protected route for the installed app's relaunch (#1024). Renders nothing. */
|
||||
function RouteMemory() {
|
||||
useRememberRoute()
|
||||
return null
|
||||
useRememberRoute();
|
||||
return null;
|
||||
}
|
||||
|
||||
function RootRedirect() {
|
||||
const { isAuthenticated, isLoading } = useAuthStore()
|
||||
const settingsLoaded = useSettingsStore((s) => s.isLoaded)
|
||||
const settings = useSettingsStore((s) => s.settings)
|
||||
const loadSettings = useSettingsStore((s) => s.loadSettings)
|
||||
const [target, setTarget] = useState<string | null>(null)
|
||||
const { isAuthenticated, isLoading } = useAuthStore();
|
||||
const settingsLoaded = useSettingsStore((s) => s.isLoaded);
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const loadSettings = useSettingsStore((s) => s.loadSettings);
|
||||
const [target, setTarget] = useState<string | null>(null);
|
||||
// Bounds the wait below: loadSettings deliberately leaves isLoaded false on a
|
||||
// failed request so it can retry, which would otherwise strand a launch that
|
||||
// started offline on the spinner.
|
||||
const [settingsGaveUp, setSettingsGaveUp] = useState(false)
|
||||
const [settingsGaveUp, setSettingsGaveUp] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsLoaded) return
|
||||
const timer = setTimeout(() => setSettingsGaveUp(true), SETTINGS_WAIT_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [settingsLoaded])
|
||||
if (settingsLoaded) return;
|
||||
const timer = setTimeout(() => setSettingsGaveUp(true), SETTINGS_WAIT_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [settingsLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !isAuthenticated || target) return
|
||||
if (isLoading || !isAuthenticated || target) return;
|
||||
// The installed app coming back after the system threw it away (#1024):
|
||||
// back to the trip and tab it was in, ahead of the start page.
|
||||
const resume = takeResumeRoute()
|
||||
if (resume) { setTarget(resume); return }
|
||||
const mirrored = readStartDestination()
|
||||
const resume = takeResumeRoute();
|
||||
if (resume) {
|
||||
setTarget(resume);
|
||||
return;
|
||||
}
|
||||
const mirrored = readStartDestination();
|
||||
if (!mirrored && !settingsLoaded && !settingsGaveUp) {
|
||||
// Ask for the settings instead of waiting for whoever else might. The
|
||||
// store de-dupes concurrent loads, so this is free when one is already in
|
||||
// flight — and it stops the decision from riding on where that request
|
||||
// happens to land in the queue behind everything else a launch fires off.
|
||||
loadSettings()
|
||||
return
|
||||
loadSettings();
|
||||
return;
|
||||
}
|
||||
// Loaded settings are the truth; the mirror is what we knew last time.
|
||||
const { page, tab } = settingsLoaded
|
||||
? { page: settings.start_page ?? DEFAULT_START_PAGE, tab: settings.start_trip_tab ?? DEFAULT_START_TRIP_TAB }
|
||||
: (mirrored ?? { page: DEFAULT_START_PAGE, tab: DEFAULT_START_TRIP_TAB })
|
||||
if (page !== 'active_trip') { setTarget('/dashboard'); return }
|
||||
let cancelled = false
|
||||
: (mirrored ?? { page: DEFAULT_START_PAGE, tab: DEFAULT_START_TRIP_TAB });
|
||||
if (page !== 'active_trip') {
|
||||
setTarget('/dashboard');
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
// Through the repo, not the api: offline this answers from Dexie instead of
|
||||
// bouncing a cached trip to the dashboard.
|
||||
tripRepo.active()
|
||||
.then(({ trip }) => { if (!cancelled) setTarget(trip ? tripStartPath(trip.id, tab) : '/dashboard') })
|
||||
.catch(() => { if (!cancelled) setTarget('/dashboard') })
|
||||
return () => { cancelled = true }
|
||||
}, [isLoading, isAuthenticated, settingsLoaded, settingsGaveUp, settings, target])
|
||||
tripRepo
|
||||
.active()
|
||||
.then(({ trip }) => {
|
||||
if (!cancelled) setTarget(trip ? tripStartPath(trip.id, tab) : '/dashboard');
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setTarget('/dashboard');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isLoading, isAuthenticated, settingsLoaded, settingsGaveUp, settings, target]);
|
||||
|
||||
if (isLoading || (isAuthenticated && !target)) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-slate-900"></div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return <Navigate to={isAuthenticated ? (target ?? '/dashboard') : '/login'} replace />
|
||||
return <Navigate to={isAuthenticated ? (target ?? '/dashboard') : '/login'} replace />;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,79 +301,127 @@ function RouteFallback() {
|
||||
// The fallback renders above MobileShell, so it has to pick its own palette —
|
||||
// on a phone the desktop spinner on bg-surface would be a foreign white sheet
|
||||
// in front of the mobile screen.
|
||||
const isPhone = useIsPhone()
|
||||
if (isPhone) return <MRouteFallback />
|
||||
const isPhone = useIsPhone();
|
||||
if (isPhone) return <MRouteFallback />;
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface">
|
||||
<div className="w-10 h-10 border-4 border-edge border-t-content rounded-full animate-spin"></div>
|
||||
<div className="flex min-h-screen items-center justify-center bg-surface">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-4 border-edge border-t-content"></div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setPlacesProvider, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
const { loadPlugins } = usePluginStore()
|
||||
const {
|
||||
loadUser,
|
||||
isAuthenticated,
|
||||
demoMode,
|
||||
setManaged,
|
||||
setDemoMode,
|
||||
setDevMode,
|
||||
setIsPrerelease,
|
||||
setAppVersion,
|
||||
setHasMapsKey,
|
||||
setHasAmapKey,
|
||||
setPlacesProvider,
|
||||
setServerTimezone,
|
||||
setAppRequireMfa,
|
||||
setTripRemindersEnabled,
|
||||
setPlacesPhotosEnabled,
|
||||
setPlacesAutocompleteEnabled,
|
||||
setPlacesDetailsEnabled,
|
||||
setPlacesEnrichEnabled,
|
||||
setPlaceShadowEnabled,
|
||||
} = useAuthStore();
|
||||
const { loadSettings } = useSettingsStore();
|
||||
const { loadAddons } = useAddonStore();
|
||||
const { loadPlugins } = usePluginStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
|
||||
if (
|
||||
!location.pathname.startsWith('/shared/') &&
|
||||
!location.pathname.startsWith('/public/') &&
|
||||
!location.pathname.startsWith('/login')
|
||||
) {
|
||||
// If the persist snapshot already has an authenticated user, validate
|
||||
// silently so the PWA shell renders immediately without a spinner.
|
||||
const alreadyAuthenticated = useAuthStore.getState().isAuthenticated
|
||||
const alreadyAuthenticated = useAuthStore.getState().isAuthenticated;
|
||||
if (alreadyAuthenticated) {
|
||||
useAuthStore.setState({ isLoading: false })
|
||||
loadUser({ silent: true })
|
||||
useAuthStore.setState({ isLoading: false });
|
||||
loadUser({ silent: true });
|
||||
} else {
|
||||
loadUser()
|
||||
loadUser();
|
||||
}
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; max_upload_mb?: number; permissions?: Record<string, PermissionLevel> }) => {
|
||||
setManaged(!!config?.managed)
|
||||
setDemoMode(!!config?.demo_mode)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
|
||||
if (config?.version) setAppVersion(config.version)
|
||||
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
||||
if (config?.has_amap_key !== undefined) setHasAmapKey(config.has_amap_key)
|
||||
if (config?.places_provider) setPlacesProvider(config.places_provider)
|
||||
if (config?.timezone) setServerTimezone(config.timezone)
|
||||
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
|
||||
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
|
||||
if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled)
|
||||
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
|
||||
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
|
||||
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled)
|
||||
if (config?.place_shadow_enabled !== undefined) setPlaceShadowEnabled(config.place_shadow_enabled)
|
||||
if (typeof config?.max_upload_mb === 'number' && config.max_upload_mb > 0) useAuthStore.getState().setMaxUploadMb(config.max_upload_mb)
|
||||
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||
// The version this page starts out with, before the realtime socket reports.
|
||||
useServerVersionStore.getState().note(config?.version)
|
||||
// Last, since a new release reloads the page from here.
|
||||
await reconcileAppVersion(config?.version)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
authApi
|
||||
.getAppConfig()
|
||||
.then(
|
||||
async (config: {
|
||||
managed?: boolean;
|
||||
demo_mode?: boolean;
|
||||
dev_mode?: boolean;
|
||||
is_prerelease?: boolean;
|
||||
has_maps_key?: boolean;
|
||||
has_amap_key?: boolean;
|
||||
places_provider?: string;
|
||||
version?: string;
|
||||
timezone?: string;
|
||||
require_mfa?: boolean;
|
||||
trip_reminders_enabled?: boolean;
|
||||
places_photos_enabled?: boolean;
|
||||
places_autocomplete_enabled?: boolean;
|
||||
places_details_enabled?: boolean;
|
||||
places_enrich_enabled?: boolean;
|
||||
place_shadow_enabled?: boolean;
|
||||
max_upload_mb?: number;
|
||||
permissions?: Record<string, PermissionLevel>;
|
||||
}) => {
|
||||
setManaged(!!config?.managed);
|
||||
setDemoMode(!!config?.demo_mode);
|
||||
if (config?.dev_mode) setDevMode(true);
|
||||
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease);
|
||||
if (config?.version) setAppVersion(config.version);
|
||||
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key);
|
||||
if (config?.has_amap_key !== undefined) setHasAmapKey(config.has_amap_key);
|
||||
if (config?.places_provider) setPlacesProvider(config.places_provider);
|
||||
if (config?.timezone) setServerTimezone(config.timezone);
|
||||
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa);
|
||||
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled);
|
||||
if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled);
|
||||
if (config?.places_autocomplete_enabled !== undefined)
|
||||
setPlacesAutocompleteEnabled(config.places_autocomplete_enabled);
|
||||
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled);
|
||||
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled);
|
||||
if (config?.place_shadow_enabled !== undefined) setPlaceShadowEnabled(config.place_shadow_enabled);
|
||||
if (typeof config?.max_upload_mb === 'number' && config.max_upload_mb > 0)
|
||||
useAuthStore.getState().setMaxUploadMb(config.max_upload_mb);
|
||||
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions);
|
||||
// Last, since a new release reloads the page from here.
|
||||
await reconcileAppVersion(config?.version);
|
||||
}
|
||||
)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const { settings } = useSettingsStore()
|
||||
const { settings } = useSettingsStore();
|
||||
|
||||
useInAppNotificationListener()
|
||||
useRoadtripPreferencesSync()
|
||||
useInAppNotificationListener();
|
||||
useRoadtripPreferencesSync();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadSettings()
|
||||
loadAddons()
|
||||
loadPlugins()
|
||||
loadSettings();
|
||||
loadAddons();
|
||||
loadPlugins();
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
registerSyncTriggers()
|
||||
return () => unregisterSyncTriggers()
|
||||
}, [])
|
||||
registerSyncTriggers();
|
||||
return () => unregisterSyncTriggers();
|
||||
}, []);
|
||||
|
||||
const location = useLocation()
|
||||
const isSharedPage = location.pathname.startsWith('/shared/')
|
||||
const location = useLocation();
|
||||
const isSharedPage = location.pathname.startsWith('/shared/');
|
||||
|
||||
useEffect(() => {
|
||||
const run = () =>
|
||||
@@ -370,22 +429,23 @@ export default function App() {
|
||||
darkMode: settings.dark_mode,
|
||||
appearance: settings.appearance,
|
||||
isSharedPage,
|
||||
})
|
||||
run()
|
||||
});
|
||||
run();
|
||||
// Re-resolve on OS theme change while in auto mode.
|
||||
if (!isSharedPage && settings.dark_mode === 'auto') {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handler = () => run()
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = () => run();
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}
|
||||
}, [settings.dark_mode, settings.appearance, isSharedPage])
|
||||
}, [settings.dark_mode, settings.appearance, isSharedPage]);
|
||||
|
||||
const isPhone = useIsPhone()
|
||||
const isAuthPage = location.pathname.startsWith('/login')
|
||||
|| location.pathname.startsWith('/register')
|
||||
|| location.pathname.startsWith('/forgot-password')
|
||||
|| location.pathname.startsWith('/reset-password')
|
||||
const isPhone = useIsPhone();
|
||||
const isAuthPage =
|
||||
location.pathname.startsWith('/login') ||
|
||||
location.pathname.startsWith('/register') ||
|
||||
location.pathname.startsWith('/forgot-password') ||
|
||||
location.pathname.startsWith('/reset-password');
|
||||
// No session on these, so authenticated-only widgets (system notices,
|
||||
// background tasks, save-to-collection) have nothing to do and would only fire
|
||||
// a doomed authenticated request on mount.
|
||||
@@ -394,17 +454,32 @@ export default function App() {
|
||||
// drifted, this one naming only /public/journey/ while the response
|
||||
// interceptor's covers all of /public/. A route that is public to one and not
|
||||
// to the other is exactly the seam that puts a 401 back.
|
||||
const hideAuthedWidgets = isAuthPage || isAuthPublicPath(location.pathname)
|
||||
const hideAuthedWidgets = isAuthPage || isAuthPublicPath(location.pathname);
|
||||
|
||||
return (
|
||||
<TranslationProvider>
|
||||
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:system-notice" fallback={null}><SystemNoticeHost /></ErrorBoundary>}
|
||||
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:help-panel" fallback={null}><HelpPanel /></ErrorBoundary>}
|
||||
<ErrorBoundary boundaryId="widget:toast" fallback={null}><ToastContainer /></ErrorBoundary>
|
||||
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
|
||||
{!hideAuthedWidgets && (
|
||||
<ErrorBoundary boundaryId="widget:system-notice" fallback={null}>
|
||||
<SystemNoticeHost />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
{!hideAuthedWidgets && (
|
||||
<ErrorBoundary boundaryId="widget:help-panel" fallback={null}>
|
||||
<HelpPanel />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
<ErrorBoundary boundaryId="widget:toast" fallback={null}>
|
||||
<ToastContainer />
|
||||
</ErrorBoundary>
|
||||
{!hideAuthedWidgets && (
|
||||
<ErrorBoundary boundaryId="widget:background-tasks" fallback={null}>
|
||||
<BackgroundTasksWidget />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
||||
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
|
||||
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
|
||||
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}>
|
||||
<OfflineBanner />
|
||||
</ErrorBoundary>
|
||||
{/* One boundary for all route chunks, above <Routes> so it stays mounted
|
||||
across navigations. react-router runs location updates inside a transition,
|
||||
so a mounted boundary keeps the current page on screen instead of flashing
|
||||
@@ -412,14 +487,63 @@ export default function App() {
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<PublicRoute redirectAuthed><LoginPage /></PublicRoute>} />
|
||||
<Route path="/shared/:token" element={<PublicRoute><SharedTripPage /></PublicRoute>} />
|
||||
<Route path="/public/journey/:token" element={<PublicRoute><JourneyPublicPage /></PublicRoute>} />
|
||||
<Route path="/register" element={<PublicRoute redirectAuthed><LoginPage /></PublicRoute>} />
|
||||
<Route path="/forgot-password" element={<PublicRoute><ForgotPasswordPage /></PublicRoute>} />
|
||||
<Route path="/reset-password" element={<PublicRoute><ResetPasswordPage /></PublicRoute>} />
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute redirectAuthed>
|
||||
<LoginPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/shared/:token"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<SharedTripPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/public/journey/:token"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<JourneyPublicPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={
|
||||
<PublicRoute redirectAuthed>
|
||||
<LoginPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/forgot-password"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<ForgotPasswordPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/reset-password"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<ResetPasswordPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
||||
<Route path="/oauth/consent" element={<PublicRoute><OAuthAuthorizePage /></PublicRoute>} />
|
||||
<Route
|
||||
path="/oauth/consent"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<OAuthAuthorizePage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
@@ -496,7 +620,7 @@ export default function App() {
|
||||
/>
|
||||
{/* Empty in this repository, and read unconditionally so the public
|
||||
build walks the same path as any other. See client/src/managed. */}
|
||||
{managedRoutes.map(r => (
|
||||
{managedRoutes.map((r) => (
|
||||
<Route
|
||||
key={r.path}
|
||||
path={r.path}
|
||||
@@ -568,5 +692,5 @@ export default function App() {
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</TranslationProvider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React from 'react'
|
||||
import { render, waitFor, act } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../tests/helpers/msw/server'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import { resetAllStores } from '../tests/helpers/store'
|
||||
import App from './App'
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { server } from '../tests/helpers/msw/server';
|
||||
import { resetAllStores } from '../tests/helpers/store';
|
||||
import App from './App';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
|
||||
/*
|
||||
* #2458: an installed PWA kept serving the previous UI after a server upgrade.
|
||||
@@ -23,233 +22,244 @@ import App from './App'
|
||||
*/
|
||||
const sw = vi.hoisted(() => {
|
||||
class FakeWorker extends EventTarget {
|
||||
state: string
|
||||
readonly scriptURL = '/sw.js'
|
||||
state: string;
|
||||
readonly scriptURL = '/sw.js';
|
||||
constructor(state: string) {
|
||||
super()
|
||||
this.state = state
|
||||
super();
|
||||
this.state = state;
|
||||
}
|
||||
become(state: string) {
|
||||
this.state = state
|
||||
this.dispatchEvent(new Event('statechange'))
|
||||
this.state = state;
|
||||
this.dispatchEvent(new Event('statechange'));
|
||||
}
|
||||
}
|
||||
class FakeRegistration extends EventTarget {
|
||||
active: FakeWorker | null = null
|
||||
installing: FakeWorker | null = null
|
||||
waiting: FakeWorker | null = null
|
||||
readonly scope = '/'
|
||||
update = vi.fn()
|
||||
active: FakeWorker | null = null;
|
||||
installing: FakeWorker | null = null;
|
||||
waiting: FakeWorker | null = null;
|
||||
readonly scope = '/';
|
||||
update = vi.fn();
|
||||
}
|
||||
class FakeContainer extends EventTarget {
|
||||
controller: FakeWorker | null = null
|
||||
getRegistration = vi.fn()
|
||||
controller: FakeWorker | null = null;
|
||||
getRegistration = vi.fn();
|
||||
// Listeners a page adds die with that page. Those added while modules load
|
||||
// (before `trackPage` is switched on) stand for module-level code and stay.
|
||||
trackPage = false
|
||||
private pageListeners: Array<[string, EventListenerOrEventListenerObject, boolean | AddEventListenerOptions | undefined]> = []
|
||||
addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions) {
|
||||
super.addEventListener(type, listener, options)
|
||||
if (this.trackPage && listener) this.pageListeners.push([type, listener, options])
|
||||
trackPage = false;
|
||||
private pageListeners: Array<
|
||||
[string, EventListenerOrEventListenerObject, boolean | AddEventListenerOptions | undefined]
|
||||
> = [];
|
||||
addEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject | null,
|
||||
options?: boolean | AddEventListenerOptions
|
||||
) {
|
||||
super.addEventListener(type, listener, options);
|
||||
if (this.trackPage && listener) this.pageListeners.push([type, listener, options]);
|
||||
}
|
||||
dropPageListeners() {
|
||||
for (const [type, listener, options] of this.pageListeners) super.removeEventListener(type, listener, options)
|
||||
this.pageListeners = []
|
||||
for (const [type, listener, options] of this.pageListeners) super.removeEventListener(type, listener, options);
|
||||
this.pageListeners = [];
|
||||
}
|
||||
}
|
||||
const oldWorker = new FakeWorker('activated')
|
||||
const registration = new FakeRegistration()
|
||||
const container = new FakeContainer()
|
||||
registration.active = oldWorker
|
||||
container.controller = oldWorker
|
||||
container.getRegistration.mockImplementation(async () => registration)
|
||||
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
|
||||
return { FakeWorker, registration, container, oldWorker }
|
||||
})
|
||||
const oldWorker = new FakeWorker('activated');
|
||||
const registration = new FakeRegistration();
|
||||
const container = new FakeContainer();
|
||||
registration.active = oldWorker;
|
||||
container.controller = oldWorker;
|
||||
container.getRegistration.mockImplementation(async () => registration);
|
||||
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container });
|
||||
return { FakeWorker, registration, container, oldWorker };
|
||||
});
|
||||
|
||||
vi.mock('./pages/LoginPage', () => ({ default: () => <div>Login</div> }))
|
||||
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>Dashboard</div> }))
|
||||
vi.mock('./hooks/useInAppNotificationListener.ts', () => ({ useInAppNotificationListener: vi.fn() }))
|
||||
vi.mock('./pages/LoginPage', () => ({ default: () => <div>Login</div> }));
|
||||
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>Dashboard</div> }));
|
||||
vi.mock('./hooks/useInAppNotificationListener.ts', () => ({ useInAppNotificationListener: vi.fn() }));
|
||||
|
||||
// Every import above has been evaluated by now; from here on a listener belongs to a page.
|
||||
sw.container.trackPage = true
|
||||
sw.container.trackPage = true;
|
||||
|
||||
const reload = vi.fn()
|
||||
let configHits = 0
|
||||
const reload = vi.fn();
|
||||
let configHits = 0;
|
||||
|
||||
function serveVersion(version: string, gate?: Promise<void>) {
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', async () => {
|
||||
configHits++
|
||||
if (gate) await gate
|
||||
return HttpResponse.json({ version })
|
||||
}),
|
||||
)
|
||||
configHits++;
|
||||
if (gate) await gate;
|
||||
return HttpResponse.json({ version });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** The browser's handover: the new worker installs, activates and claims the page. */
|
||||
function handOver() {
|
||||
const next = new sw.FakeWorker('activated')
|
||||
sw.registration.installing = null
|
||||
sw.registration.active = next
|
||||
sw.container.controller = next
|
||||
sw.container.dispatchEvent(new Event('controllerchange'))
|
||||
const next = new sw.FakeWorker('activated');
|
||||
sw.registration.installing = null;
|
||||
sw.registration.active = next;
|
||||
sw.container.controller = next;
|
||||
sw.container.dispatchEvent(new Event('controllerchange'));
|
||||
}
|
||||
|
||||
/** update() finds a new sw.js and starts installing it; `then` decides how the install ends. */
|
||||
function updateStartsInstall(then: 'activates' | 'fails') {
|
||||
sw.registration.update.mockImplementation(async () => {
|
||||
const next = new sw.FakeWorker('installing')
|
||||
sw.registration.installing = next
|
||||
const next = new sw.FakeWorker('installing');
|
||||
sw.registration.installing = next;
|
||||
setTimeout(() => {
|
||||
if (then === 'activates') {
|
||||
next.become('activated')
|
||||
handOver()
|
||||
next.become('activated');
|
||||
handOver();
|
||||
} else {
|
||||
// Workbox precaching is all-or-nothing: one failed fetch out of ~463
|
||||
// rejects the install event and the new worker is thrown away.
|
||||
sw.registration.installing = null
|
||||
next.become('redundant')
|
||||
sw.registration.installing = null;
|
||||
next.become('redundant');
|
||||
}
|
||||
}, 0)
|
||||
return sw.registration
|
||||
})
|
||||
}, 0);
|
||||
return sw.registration;
|
||||
});
|
||||
}
|
||||
|
||||
/** One cold start of the installed app: fresh page, fresh session, same localStorage. */
|
||||
async function launch() {
|
||||
sessionStorage.clear()
|
||||
const hitsBefore = configHits
|
||||
sessionStorage.clear();
|
||||
const hitsBefore = configHits;
|
||||
const view = render(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
await waitFor(() => expect(configHits).toBe(hitsBefore + 1))
|
||||
</MemoryRouter>
|
||||
);
|
||||
await waitFor(() => expect(configHits).toBe(hitsBefore + 1));
|
||||
// Let the probe, update() and the fake install run to completion.
|
||||
await act(async () => { await new Promise(r => setTimeout(r, 50)) })
|
||||
view.unmount()
|
||||
sw.container.dropPageListeners()
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
view.unmount();
|
||||
sw.container.dropPageListeners();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
vi.clearAllMocks()
|
||||
configHits = 0
|
||||
sw.registration.active = sw.oldWorker
|
||||
sw.registration.installing = null
|
||||
sw.registration.waiting = null
|
||||
sw.registration.update.mockReset()
|
||||
sw.registration.update.mockResolvedValue(sw.registration)
|
||||
sw.container.controller = sw.oldWorker
|
||||
sw.container.dropPageListeners()
|
||||
sw.container.getRegistration.mockReset()
|
||||
sw.container.getRegistration.mockImplementation(async () => sw.registration)
|
||||
reload.mockReset()
|
||||
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } })
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
configHits = 0;
|
||||
sw.registration.active = sw.oldWorker;
|
||||
sw.registration.installing = null;
|
||||
sw.registration.waiting = null;
|
||||
sw.registration.update.mockReset();
|
||||
sw.registration.update.mockResolvedValue(sw.registration);
|
||||
sw.container.controller = sw.oldWorker;
|
||||
sw.container.dropPageListeners();
|
||||
sw.container.getRegistration.mockReset();
|
||||
sw.container.getRegistration.mockImplementation(async () => sw.registration);
|
||||
reload.mockReset();
|
||||
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } });
|
||||
server.use(
|
||||
http.get('/api/health/features', () => HttpResponse.json({ bookingImport: false, aiParsing: false })),
|
||||
http.get('/api/system-notices/active', () => HttpResponse.json([])),
|
||||
)
|
||||
http.get('/api/system-notices/active', () => HttpResponse.json([]))
|
||||
);
|
||||
useAuthStore.setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
appRequireMfa: false,
|
||||
loadUser: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2458 version handover: behaviour that must survive the fix', () => {
|
||||
it('FE-COMP-APP-2458-P1: a confirmed handover reloads once and is not repeated on the next launch', async () => {
|
||||
localStorage.setItem('trek_app_version', '4.3.0')
|
||||
serveVersion('4.3.1')
|
||||
updateStartsInstall('activates')
|
||||
localStorage.setItem('trek_app_version', '4.3.0');
|
||||
serveVersion('4.3.1');
|
||||
updateStartsInstall('activates');
|
||||
|
||||
await launch()
|
||||
expect(sw.registration.update).toHaveBeenCalledTimes(1)
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
expect(localStorage.getItem('trek_app_version')).toBe('4.3.1')
|
||||
await launch();
|
||||
expect(sw.registration.update).toHaveBeenCalledTimes(1);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
expect(localStorage.getItem('trek_app_version')).toBe('4.3.1');
|
||||
|
||||
await launch()
|
||||
expect(sw.registration.update).toHaveBeenCalledTimes(1)
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
await launch();
|
||||
expect(sw.registration.update).toHaveBeenCalledTimes(1);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-APP-2458-P2: nothing to hand over (sw.js unchanged, same worker in control) neither reloads nor retries', async () => {
|
||||
// A release that changed nothing the precache holds leaves sw.js byte-identical,
|
||||
// so update() installs nothing. The bundle this page runs is already current.
|
||||
localStorage.setItem('trek_app_version', '4.3.0')
|
||||
serveVersion('4.3.1')
|
||||
localStorage.setItem('trek_app_version', '4.3.0');
|
||||
serveVersion('4.3.1');
|
||||
|
||||
await launch()
|
||||
expect(sw.registration.update).toHaveBeenCalledTimes(1)
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
await launch();
|
||||
expect(sw.registration.update).toHaveBeenCalledTimes(1);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
await launch()
|
||||
expect(sw.registration.update).toHaveBeenCalledTimes(1)
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
await launch();
|
||||
expect(sw.registration.update).toHaveBeenCalledTimes(1);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a self-built image without the build arg', 'dev'],
|
||||
['a test instance pinned to an older tag', '4.0.0'],
|
||||
['a prerelease image, whose bundle keeps the last stable version', '4.4.0-pre.3'],
|
||||
])('FE-COMP-APP-2458-P3: %s (server says %s, bundle is built as another version) never loops', async (_label, serverVersion) => {
|
||||
// The server reports APP_VERSION, the bundle carries client/package.json. On
|
||||
// these setups the two never agree, so a check against the bundle's own
|
||||
// version would see an update on every launch.
|
||||
serveVersion(serverVersion)
|
||||
])(
|
||||
'FE-COMP-APP-2458-P3: %s (server says %s, bundle is built as another version) never loops',
|
||||
async (_label, serverVersion) => {
|
||||
// The server reports APP_VERSION, the bundle carries client/package.json. On
|
||||
// these setups the two never agree, so a check against the bundle's own
|
||||
// version would see an update on every launch.
|
||||
serveVersion(serverVersion);
|
||||
|
||||
await launch()
|
||||
await launch()
|
||||
await launch()
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
expect(sw.registration.update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
await launch();
|
||||
await launch();
|
||||
await launch();
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(sw.registration.update).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('#2458 version handover: the reported defect', () => {
|
||||
it('FE-COMP-APP-2458-01: an update() that rejects is retried on the next launch', async () => {
|
||||
localStorage.setItem('trek_app_version', '4.3.0')
|
||||
serveVersion('4.3.1')
|
||||
sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error'))
|
||||
localStorage.setItem('trek_app_version', '4.3.0');
|
||||
serveVersion('4.3.1');
|
||||
sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error'));
|
||||
|
||||
await launch()
|
||||
expect(sw.registration.update).toHaveBeenCalledTimes(1)
|
||||
await launch();
|
||||
expect(sw.registration.update).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Next cold start, network fine this time.
|
||||
updateStartsInstall('activates')
|
||||
await launch()
|
||||
expect(sw.registration.update).toHaveBeenCalledTimes(2)
|
||||
expect(reload).toHaveBeenCalled()
|
||||
})
|
||||
updateStartsInstall('activates');
|
||||
await launch();
|
||||
expect(sw.registration.update).toHaveBeenCalledTimes(2);
|
||||
expect(reload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-APP-2458-02: a failed update() does not reload into the shell the same worker serves again', async () => {
|
||||
// The old worker still controls the page, so a plain reload is answered from
|
||||
// its precache: the same old UI, now with the marker saying it is current.
|
||||
localStorage.setItem('trek_app_version', '4.3.0')
|
||||
serveVersion('4.3.1')
|
||||
sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error'))
|
||||
localStorage.setItem('trek_app_version', '4.3.0');
|
||||
serveVersion('4.3.1');
|
||||
sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error'));
|
||||
|
||||
await launch()
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
await launch();
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-APP-2458-03: an install that goes redundant is retried on the next launch', async () => {
|
||||
localStorage.setItem('trek_app_version', '4.3.0')
|
||||
serveVersion('4.3.1')
|
||||
updateStartsInstall('fails')
|
||||
localStorage.setItem('trek_app_version', '4.3.0');
|
||||
serveVersion('4.3.1');
|
||||
updateStartsInstall('fails');
|
||||
|
||||
await launch()
|
||||
expect(sw.registration.update).toHaveBeenCalledTimes(1)
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
await launch();
|
||||
expect(sw.registration.update).toHaveBeenCalledTimes(1);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
updateStartsInstall('activates')
|
||||
await launch()
|
||||
expect(sw.registration.update).toHaveBeenCalledTimes(2)
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
updateStartsInstall('activates');
|
||||
await launch();
|
||||
expect(sw.registration.update).toHaveBeenCalledTimes(2);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Kept last: it swaps the controlling worker mid-launch, which code that
|
||||
// remembers "the controller changed since this page loaded" keeps seeing.
|
||||
@@ -258,24 +268,28 @@ describe('#2458 version handover: the reported defect', () => {
|
||||
// installs and activates the new worker before /api/auth/app-config has
|
||||
// answered, the takeover happens while nobody listens, and update() then
|
||||
// finds nothing newer. The page keeps running the bundle the old worker served.
|
||||
localStorage.setItem('trek_app_version', '4.3.0')
|
||||
let open!: () => void
|
||||
const gate = new Promise<void>(r => { open = r })
|
||||
serveVersion('4.3.1', gate)
|
||||
localStorage.setItem('trek_app_version', '4.3.0');
|
||||
let open!: () => void;
|
||||
const gate = new Promise<void>((r) => {
|
||||
open = r;
|
||||
});
|
||||
serveVersion('4.3.1', gate);
|
||||
|
||||
sessionStorage.clear()
|
||||
sessionStorage.clear();
|
||||
const view = render(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
await waitFor(() => expect(configHits).toBe(1))
|
||||
act(() => handOver())
|
||||
open()
|
||||
await act(async () => { await new Promise(r => setTimeout(r, 50)) })
|
||||
view.unmount()
|
||||
sw.container.dropPageListeners()
|
||||
</MemoryRouter>
|
||||
);
|
||||
await waitFor(() => expect(configHits).toBe(1));
|
||||
act(() => handOver());
|
||||
open();
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
view.unmount();
|
||||
sw.container.dropPageListeners();
|
||||
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import { useAddonStore } from './store/addonStore'
|
||||
import { resetAllStores } from '../tests/helpers/store'
|
||||
import { buildUser } from '../tests/helpers/factories'
|
||||
import App from './App'
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildUser } from '../tests/helpers/factories';
|
||||
import { resetAllStores } from '../tests/helpers/store';
|
||||
import App from './App';
|
||||
import { useAddonStore } from './store/addonStore';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
|
||||
/**
|
||||
* The viewport decides which chunk gets fetched, so the switch lives in App.tsx
|
||||
@@ -17,35 +16,35 @@ import App from './App'
|
||||
* root hangs off the route, and the desktop page is never rendered at all.
|
||||
*/
|
||||
|
||||
const isPhone = vi.hoisted(() => ({ value: false }))
|
||||
vi.mock('./mobile/useIsPhone', () => ({ useIsPhone: () => isPhone.value }))
|
||||
const isPhone = vi.hoisted(() => ({ value: false }));
|
||||
vi.mock('./mobile/useIsPhone', () => ({ useIsPhone: () => isPhone.value }));
|
||||
|
||||
vi.mock('./mobile/screens/dashboard/MDashboard', () => ({ default: () => <div>m-dashboard</div> }))
|
||||
vi.mock('./mobile/screens/trip/MTripShell', () => ({ default: () => <div>m-trip</div> }))
|
||||
vi.mock('./mobile/screens/admin/MAdmin', () => ({ default: () => <div>m-admin</div> }))
|
||||
vi.mock('./mobile/screens/settings/MSettings', () => ({ default: () => <div>m-settings</div> }))
|
||||
vi.mock('./mobile/screens/vacay/MVacay', () => ({ default: () => <div>m-vacay</div> }))
|
||||
vi.mock('./mobile/screens/atlas/MAtlas', () => ({ default: () => <div>m-atlas</div> }))
|
||||
vi.mock('./mobile/screens/journey/MJourney', () => ({ default: () => <div>m-journey</div> }))
|
||||
vi.mock('./mobile/screens/journey/MJourneyDetail', () => ({ default: () => <div>m-journey-detail</div> }))
|
||||
vi.mock('./mobile/screens/collections/MCollections', () => ({ default: () => <div>m-collections</div> }))
|
||||
vi.mock('./mobile/screens/notifications/MNotifications', () => ({ default: () => <div>m-notifications</div> }))
|
||||
vi.mock('./mobile/screens/dashboard/MDashboard', () => ({ default: () => <div>m-dashboard</div> }));
|
||||
vi.mock('./mobile/screens/trip/MTripShell', () => ({ default: () => <div>m-trip</div> }));
|
||||
vi.mock('./mobile/screens/admin/MAdmin', () => ({ default: () => <div>m-admin</div> }));
|
||||
vi.mock('./mobile/screens/settings/MSettings', () => ({ default: () => <div>m-settings</div> }));
|
||||
vi.mock('./mobile/screens/vacay/MVacay', () => ({ default: () => <div>m-vacay</div> }));
|
||||
vi.mock('./mobile/screens/atlas/MAtlas', () => ({ default: () => <div>m-atlas</div> }));
|
||||
vi.mock('./mobile/screens/journey/MJourney', () => ({ default: () => <div>m-journey</div> }));
|
||||
vi.mock('./mobile/screens/journey/MJourneyDetail', () => ({ default: () => <div>m-journey-detail</div> }));
|
||||
vi.mock('./mobile/screens/collections/MCollections', () => ({ default: () => <div>m-collections</div> }));
|
||||
vi.mock('./mobile/screens/notifications/MNotifications', () => ({ default: () => <div>m-notifications</div> }));
|
||||
|
||||
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>d-dashboard</div> }))
|
||||
vi.mock('./pages/TripPlannerPage', () => ({ default: () => <div>d-trip</div> }))
|
||||
vi.mock('./pages/AdminPage', () => ({ default: () => <div>d-admin</div> }))
|
||||
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>d-settings</div> }))
|
||||
vi.mock('./pages/VacayPage', () => ({ default: () => <div>d-vacay</div> }))
|
||||
vi.mock('./pages/AtlasPage', () => ({ default: () => <div>d-atlas</div> }))
|
||||
vi.mock('./pages/JourneyPage', () => ({ default: () => <div>d-journey</div> }))
|
||||
vi.mock('./pages/JourneyDetailPage', () => ({ default: () => <div>d-journey-detail</div> }))
|
||||
vi.mock('./pages/CollectionsPage', () => ({ default: () => <div>d-collections</div> }))
|
||||
vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () => <div>d-notifications</div> }))
|
||||
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>d-dashboard</div> }));
|
||||
vi.mock('./pages/TripPlannerPage', () => ({ default: () => <div>d-trip</div> }));
|
||||
vi.mock('./pages/AdminPage', () => ({ default: () => <div>d-admin</div> }));
|
||||
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>d-settings</div> }));
|
||||
vi.mock('./pages/VacayPage', () => ({ default: () => <div>d-vacay</div> }));
|
||||
vi.mock('./pages/AtlasPage', () => ({ default: () => <div>d-atlas</div> }));
|
||||
vi.mock('./pages/JourneyPage', () => ({ default: () => <div>d-journey</div> }));
|
||||
vi.mock('./pages/JourneyDetailPage', () => ({ default: () => <div>d-journey-detail</div> }));
|
||||
vi.mock('./pages/CollectionsPage', () => ({ default: () => <div>d-collections</div> }));
|
||||
vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () => <div>d-notifications</div> }));
|
||||
|
||||
// The notification listener opens a WebSocket on mount.
|
||||
vi.mock('./hooks/useInAppNotificationListener.ts', () => ({
|
||||
useInAppNotificationListener: vi.fn(),
|
||||
}))
|
||||
}));
|
||||
|
||||
/** path, mobile marker, desktop marker */
|
||||
const ROUTES: [string, string, string][] = [
|
||||
@@ -60,20 +59,20 @@ const ROUTES: [string, string, string][] = [
|
||||
['/collections', 'm-collections', 'd-collections'],
|
||||
['/collections/1', 'm-collections', 'd-collections'],
|
||||
['/notifications', 'm-notifications', 'd-notifications'],
|
||||
]
|
||||
];
|
||||
|
||||
function renderAt(path: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
vi.clearAllMocks()
|
||||
isPhone.value = false
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
isPhone.value = false;
|
||||
// An admin passes the role check on /admin; every other route ignores it.
|
||||
useAuthStore.setState({
|
||||
isLoading: false,
|
||||
@@ -81,31 +80,31 @@ beforeEach(() => {
|
||||
user: buildUser({ role: 'admin', mfa_enabled: true }),
|
||||
appRequireMfa: false,
|
||||
loadUser: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
});
|
||||
// /journey and /collections sit behind addons; without this they redirect to
|
||||
// the dashboard and the route under test never renders.
|
||||
useAddonStore.setState({ loaded: true, isEnabled: () => true })
|
||||
})
|
||||
useAddonStore.setState({ loaded: true, isEnabled: () => true });
|
||||
});
|
||||
|
||||
describe('App — viewport routing', () => {
|
||||
it.each(ROUTES)(
|
||||
'FE-COMP-APPVP-001: %s renders the mobile screen below the breakpoint',
|
||||
async (path, mobile, desktop) => {
|
||||
isPhone.value = true
|
||||
renderAt(path)
|
||||
isPhone.value = true;
|
||||
renderAt(path);
|
||||
|
||||
expect(await screen.findByText(mobile)).toBeInTheDocument()
|
||||
expect(screen.queryByText(desktop)).not.toBeInTheDocument()
|
||||
expect(await screen.findByText(mobile)).toBeInTheDocument();
|
||||
expect(screen.queryByText(desktop)).not.toBeInTheDocument();
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
it.each(ROUTES)(
|
||||
'FE-COMP-APPVP-002: %s renders the desktop page above the breakpoint',
|
||||
async (path, mobile, desktop) => {
|
||||
renderAt(path)
|
||||
renderAt(path);
|
||||
|
||||
expect(await screen.findByText(desktop)).toBeInTheDocument()
|
||||
expect(screen.queryByText(mobile)).not.toBeInTheDocument()
|
||||
expect(await screen.findByText(desktop)).toBeInTheDocument();
|
||||
expect(screen.queryByText(mobile)).not.toBeInTheDocument();
|
||||
}
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// vi.unmock must run before the module is imported (tests/setup.ts mocks it globally)
|
||||
vi.unmock('./websocket')
|
||||
|
||||
// FE-WSCORE-001 to FE-WSCORE-016
|
||||
// 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, reconnectNow,
|
||||
setRefetchCallback, setPreReconnectHook,
|
||||
} from './websocket'
|
||||
|
||||
class MockWebSocket {
|
||||
@@ -265,34 +265,3 @@ describe('websocket > connection lifecycle', () => {
|
||||
expect(MockWebSocket.instances).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('websocket > logout and reconnect', () => {
|
||||
it('FE-WSCORE-015: a logout while the token is on its way opens no socket afterwards', async () => {
|
||||
server.use(http.post('/api/auth/ws-token', async () => {
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
return HttpResponse.json({ token: 'previous-user' })
|
||||
}))
|
||||
connect()
|
||||
disconnect()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(MockWebSocket.instances).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-WSCORE-016: reconnectNow skips the backoff, and does nothing while logged out or connected', async () => {
|
||||
reconnectNow()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(MockWebSocket.instances).toHaveLength(0)
|
||||
|
||||
const first = await openSocket()
|
||||
reconnectNow()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(MockWebSocket.instances).toHaveLength(1)
|
||||
|
||||
first.readyState = MockWebSocket.CLOSED
|
||||
first.onclose?.()
|
||||
reconnectNow()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(MockWebSocket.instances).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// Singleton WebSocket manager for real-time collaboration
|
||||
|
||||
import { useServerVersionStore } from '../store/serverVersionStore'
|
||||
|
||||
type WebSocketListener = (event: Record<string, unknown>) => void
|
||||
type RefetchCallback = (tripId: string) => void
|
||||
|
||||
@@ -24,9 +22,6 @@ let shouldReconnect = false
|
||||
let refetchCallback: RefetchCallback | null = null
|
||||
let mySocketId: string | null = null
|
||||
let connecting = false
|
||||
// Bumped by disconnect(): a connect that was still waiting for its token when
|
||||
// the user logged out must not open a socket afterwards.
|
||||
let generation = 0
|
||||
/** Hook run before refetchCallback on reconnect. Awaited so mutations land first. */
|
||||
let preReconnectHook: (() => Promise<void>) | null = null
|
||||
|
||||
@@ -83,7 +78,6 @@ function handleMessage(event: MessageEvent): void {
|
||||
const parsed = JSON.parse(event.data)
|
||||
if (parsed.type === 'welcome') {
|
||||
mySocketId = parsed.socketId
|
||||
useServerVersionStore.getState().note(parsed.version)
|
||||
return
|
||||
}
|
||||
listeners.forEach(fn => {
|
||||
@@ -112,12 +106,8 @@ async function connectInternal(_isReconnect = false): Promise<void> {
|
||||
}
|
||||
|
||||
connecting = true
|
||||
const started = generation
|
||||
const wsToken = await fetchWsToken()
|
||||
connecting = false
|
||||
// Logged out while the token was on its way: opening now would leave a socket
|
||||
// authenticated as the previous user, which the next login would reuse.
|
||||
if (started !== generation || !shouldReconnect) return
|
||||
|
||||
if (!wsToken) {
|
||||
if (shouldReconnect) scheduleReconnect()
|
||||
@@ -173,17 +163,6 @@ async function connectInternal(_isReconnect = false): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The network came back or the tab became visible: a socket waiting out its
|
||||
* backoff (up to 30 s) reconnects now. Does nothing while logged out or while
|
||||
* a socket is open or opening.
|
||||
*/
|
||||
export function reconnectNow(): void {
|
||||
if (!shouldReconnect) return
|
||||
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return
|
||||
connect()
|
||||
}
|
||||
|
||||
export function connect(): void {
|
||||
shouldReconnect = true
|
||||
reconnectDelay = 1000
|
||||
@@ -196,7 +175,6 @@ export function connect(): void {
|
||||
|
||||
export function disconnect(): void {
|
||||
shouldReconnect = false
|
||||
generation++
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
|
||||
@@ -120,6 +120,7 @@ export const IGNORED_WS_EVENTS = [
|
||||
'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',
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-039
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import AddonManager from './AddonManager';
|
||||
|
||||
@@ -40,7 +40,7 @@ function llmAddon(config: Record<string, unknown> = {}) {
|
||||
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 })) });
|
||||
return HttpResponse.json({ models: names.map((name) => ({ name, size: 1 })) });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,9 +71,7 @@ beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useSettingsStore, { settings: { dark_mode: false } });
|
||||
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] }))
|
||||
);
|
||||
server.use(http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -85,7 +83,7 @@ describe('AddonManager', () => {
|
||||
it('FE-ADMIN-ADDON-001: loading spinner shown while fetching', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/addons', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
return HttpResponse.json({ addons: [] });
|
||||
})
|
||||
);
|
||||
@@ -131,14 +129,15 @@ describe('AddonManager', () => {
|
||||
it('FE-ADMIN-ADDON-005: toggle enables a disabled addon (optimistic update)', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () =>
|
||||
HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })
|
||||
),
|
||||
http.put('/api/admin/addons/todo', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })),
|
||||
http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true }))
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AddonManager />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
const toggleBtn = addonToggle('Todo List');
|
||||
@@ -153,14 +152,15 @@ describe('AddonManager', () => {
|
||||
it('FE-ADMIN-ADDON-006: toggle rolls back on API failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () =>
|
||||
HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })
|
||||
),
|
||||
http.put('/api/admin/addons/todo', () =>
|
||||
HttpResponse.error()
|
||||
)
|
||||
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })),
|
||||
http.put('/api/admin/addons/todo', () => HttpResponse.error())
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AddonManager />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
await user.click(addonToggle('Todo List'));
|
||||
@@ -178,13 +178,9 @@ describe('AddonManager', () => {
|
||||
const user = userEvent.setup();
|
||||
const mockToggle = vi.fn();
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () =>
|
||||
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] })
|
||||
)
|
||||
);
|
||||
render(
|
||||
<AddonManager bagTrackingEnabled={false} onToggleBagTracking={mockToggle} />
|
||||
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] }))
|
||||
);
|
||||
render(<AddonManager bagTrackingEnabled={false} onToggleBagTracking={mockToggle} />);
|
||||
await screen.findByText('Bag Tracking');
|
||||
await user.click(subToggle('Bag Tracking'));
|
||||
expect(mockToggle).toHaveBeenCalled();
|
||||
@@ -196,18 +192,14 @@ describe('AddonManager', () => {
|
||||
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: false })] })
|
||||
)
|
||||
);
|
||||
render(
|
||||
<AddonManager bagTrackingEnabled={false} onToggleBagTracking={vi.fn()} />
|
||||
);
|
||||
render(<AddonManager bagTrackingEnabled={false} onToggleBagTracking={vi.fn()} />);
|
||||
await screen.findByText('Lists');
|
||||
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-009: bag tracking hidden when onToggleBagTracking prop not provided', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () =>
|
||||
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] })
|
||||
)
|
||||
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] }))
|
||||
);
|
||||
render(<AddonManager bagTrackingEnabled={false} />);
|
||||
await screen.findByText('Lists');
|
||||
@@ -257,7 +249,12 @@ describe('AddonManager', () => {
|
||||
|
||||
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 /></>);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AddonManager />
|
||||
</>
|
||||
);
|
||||
|
||||
await screen.findByText('Failed to update addon');
|
||||
expect(screen.getByText('No addons available')).toBeInTheDocument();
|
||||
@@ -273,11 +270,13 @@ describe('AddonManager', () => {
|
||||
});
|
||||
|
||||
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' }),
|
||||
]));
|
||||
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');
|
||||
@@ -286,12 +285,32 @@ describe('AddonManager', () => {
|
||||
});
|
||||
|
||||
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 }),
|
||||
]));
|
||||
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');
|
||||
@@ -313,14 +332,25 @@ describe('AddonManager', () => {
|
||||
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 }),
|
||||
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 />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Immich');
|
||||
|
||||
await user.click(subToggle('Immich'));
|
||||
@@ -335,11 +365,22 @@ describe('AddonManager', () => {
|
||||
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 }),
|
||||
buildAddon({
|
||||
id: 'unsplash',
|
||||
name: 'Unsplash',
|
||||
description: 'Stock photos',
|
||||
type: 'photo_provider',
|
||||
enabled: true,
|
||||
}),
|
||||
]),
|
||||
http.put('/api/admin/addons/unsplash', () => HttpResponse.error()),
|
||||
http.put('/api/admin/addons/unsplash', () => HttpResponse.error())
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AddonManager />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Unsplash');
|
||||
|
||||
await user.click(subToggle('Unsplash'));
|
||||
@@ -349,11 +390,25 @@ describe('AddonManager', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-030: provider sub-rows stay hidden while Journey itself is off', async () => {
|
||||
server.use(addonsRoute([
|
||||
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }),
|
||||
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 }),
|
||||
]));
|
||||
server.use(
|
||||
addonsRoute([
|
||||
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }),
|
||||
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,
|
||||
}),
|
||||
])
|
||||
);
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Journey');
|
||||
@@ -372,18 +427,29 @@ describe('AddonManager', () => {
|
||||
return HttpResponse.json({
|
||||
addons: [
|
||||
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: journeyOn }),
|
||||
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: immichOn }),
|
||||
buildAddon({
|
||||
id: 'immich',
|
||||
name: 'Immich',
|
||||
description: 'Self-hosted photos',
|
||||
type: 'photo_provider',
|
||||
enabled: immichOn,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
http.put('/api/admin/addons/journey', async ({ request }) => {
|
||||
journeyOn = (await request.json() as { enabled: boolean }).enabled;
|
||||
journeyOn = ((await request.json()) as { enabled: boolean }).enabled;
|
||||
// Journey off takes its providers with it, see admin.service.ts.
|
||||
if (!journeyOn) immichOn = false;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
})
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AddonManager />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Immich');
|
||||
|
||||
await user.click(addonToggle('Journey'));
|
||||
@@ -406,9 +472,14 @@ describe('AddonManager', () => {
|
||||
loads += 1;
|
||||
return HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] });
|
||||
}),
|
||||
http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true })),
|
||||
http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true }))
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AddonManager />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
await user.click(addonToggle('Todo List'));
|
||||
@@ -419,12 +490,26 @@ describe('AddonManager', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-033: document providers hang off Documents with their glyphs and stay out of the count', async () => {
|
||||
server.use(addonsRoute([
|
||||
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }),
|
||||
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }),
|
||||
buildAddon({ id: 'paperless', name: 'Paperless-ngx', description: 'Document archive', type: 'document_provider', enabled: true }),
|
||||
buildAddon({ id: 'nextcloud', name: 'Nextcloud', description: 'WebDAV files', type: 'document_provider', enabled: false }),
|
||||
]));
|
||||
server.use(
|
||||
addonsRoute([
|
||||
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }),
|
||||
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }),
|
||||
buildAddon({
|
||||
id: 'paperless',
|
||||
name: 'Paperless-ngx',
|
||||
description: 'Document archive',
|
||||
type: 'document_provider',
|
||||
enabled: true,
|
||||
}),
|
||||
buildAddon({
|
||||
id: 'nextcloud',
|
||||
name: 'Nextcloud',
|
||||
description: 'WebDAV files',
|
||||
type: 'document_provider',
|
||||
enabled: false,
|
||||
}),
|
||||
])
|
||||
);
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Paperless-ngx');
|
||||
@@ -438,10 +523,12 @@ describe('AddonManager', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-034: document providers stay hidden while Documents itself is off', async () => {
|
||||
server.use(addonsRoute([
|
||||
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: false }),
|
||||
buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: true }),
|
||||
]));
|
||||
server.use(
|
||||
addonsRoute([
|
||||
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: false }),
|
||||
buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: true }),
|
||||
])
|
||||
);
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Documents');
|
||||
@@ -464,12 +551,17 @@ describe('AddonManager', () => {
|
||||
});
|
||||
}),
|
||||
http.put('/api/admin/addons/documents', async ({ request }) => {
|
||||
documentsOn = (await request.json() as { enabled: boolean }).enabled;
|
||||
documentsOn = ((await request.json()) as { enabled: boolean }).enabled;
|
||||
if (!documentsOn) paperlessOn = false;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
})
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AddonManager />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Paperless-ngx');
|
||||
|
||||
await user.click(addonToggle('Documents'));
|
||||
@@ -500,9 +592,14 @@ describe('AddonManager', () => {
|
||||
http.put('/api/admin/addons/papra', async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
})
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AddonManager />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Papra');
|
||||
|
||||
await user.click(subToggle('Papra'));
|
||||
@@ -521,7 +618,7 @@ describe('AddonManager', () => {
|
||||
<AddonManager
|
||||
collabFeatures={{ chat: true, notes: false, polls: false, whatsnext: true }}
|
||||
onToggleCollabFeature={onToggleCollabFeature}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
|
||||
await screen.findByText('Chat');
|
||||
@@ -551,7 +648,7 @@ describe('AddonManager', () => {
|
||||
<AddonManager
|
||||
collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }}
|
||||
onToggleCollabFeature={vi.fn()}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
|
||||
const links = await screen.findByText('Links');
|
||||
@@ -566,7 +663,9 @@ describe('AddonManager', () => {
|
||||
await screen.findByText('AI Parsing');
|
||||
// The catalog key now exists, so the tile shows the translation rather than falling
|
||||
// back to the English description the server sent.
|
||||
expect(screen.getByText('Reads bookings the built-in parser cannot, using an AI model you choose')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Reads bookings the built-in parser cannot, using an AI model you choose')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Provider')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -607,16 +706,12 @@ describe('AddonManager', () => {
|
||||
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: [] });
|
||||
}),
|
||||
return calls === 1 ? HttpResponse.json({ error: 'down' }, { status: 500 }) : HttpResponse.json({ models: [] });
|
||||
})
|
||||
);
|
||||
render(<AddonManager />);
|
||||
|
||||
// The server's reason is shown, not axios' status line (#2649)
|
||||
await screen.findByText('down');
|
||||
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
|
||||
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.');
|
||||
@@ -665,11 +760,16 @@ describe('AddonManager', () => {
|
||||
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' } },
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson' } }
|
||||
);
|
||||
}),
|
||||
})
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AddonManager />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
@@ -687,15 +787,22 @@ describe('AddonManager', () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true })]),
|
||||
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 />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
@@ -721,9 +828,14 @@ describe('AddonManager', () => {
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
})
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AddonManager />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
expect(screen.getByPlaceholderText('(often not required)')).toBeInTheDocument();
|
||||
@@ -747,14 +859,27 @@ describe('AddonManager', () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local', model: '', baseUrl: 'http://ollama.lan:11434/v1', apiKey: '', multimodal: false })]),
|
||||
addonsRoute([
|
||||
llmAddon({
|
||||
provider: 'local',
|
||||
model: '',
|
||||
baseUrl: 'http://ollama.lan:11434/v1',
|
||||
apiKey: '',
|
||||
multimodal: false,
|
||||
}),
|
||||
]),
|
||||
modelsRoute([]),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
})
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AddonManager />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
|
||||
@@ -767,7 +892,13 @@ describe('AddonManager', () => {
|
||||
await screen.findByText('Saved');
|
||||
// The stale local base URL must not ride along to Anthropic — it would hijack the endpoint.
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' },
|
||||
config: {
|
||||
provider: 'anthropic',
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
baseUrl: '',
|
||||
apiKey: 'sk-ant-live',
|
||||
vision: 'auto',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -779,13 +910,20 @@ describe('AddonManager', () => {
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
})
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AddonManager />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
// Stored off stays off; a cloud provider is told what Automatic means for it.
|
||||
await user.click(await screen.findByRole('button', { name: 'No' }));
|
||||
expect(screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Yes' }));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
@@ -797,7 +935,9 @@ describe('AddonManager', () => {
|
||||
it('FE-ADMIN-ADDON-039: with the local provider the hint says Automatic asks the server', async () => {
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local', model: 'qwen3.5:4b' })]), modelsRoute([]));
|
||||
render(<AddonManager />);
|
||||
expect(await screen.findByText('Automatic asks the Ollama server whether this model reads images.')).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText('Automatic asks the Ollama server whether this model reads images.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -806,12 +946,20 @@ describe('AddonManager', () => {
|
||||
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' } },
|
||||
)),
|
||||
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 />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
|
||||
@@ -1,32 +1,93 @@
|
||||
import { useId, type ComponentType } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import AirTrailIcon from '../shared/AirTrailIcon'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons'
|
||||
import AddonTile from './AddonTile'
|
||||
import AddonSubRow from './AddonSubRow'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
||||
import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit'
|
||||
import { type Addon, type CollabFeatures, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel'
|
||||
import { useAddonManager } from './addons/useAddonManager'
|
||||
import { useLlmParsingConfig } from './addons/useLlmParsingConfig'
|
||||
import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons'
|
||||
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared';
|
||||
import {
|
||||
BarChart3,
|
||||
Bookmark,
|
||||
BookOpen,
|
||||
Briefcase,
|
||||
CalendarDays,
|
||||
Cloud,
|
||||
Compass,
|
||||
FileText,
|
||||
Globe,
|
||||
Image,
|
||||
Link2,
|
||||
ListChecks,
|
||||
Loader2,
|
||||
Luggage,
|
||||
MessageCircle,
|
||||
Plane,
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Sparkles,
|
||||
StickyNote,
|
||||
Terminal,
|
||||
Users,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useId, useState, type ComponentType } from 'react';
|
||||
import { adminApi } from '../../api/client';
|
||||
import { useIsDark } from '../../hooks/useIsDark';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { SETTINGS_BUTTON_PRIMARY, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit';
|
||||
import AirTrailIcon from '../shared/AirTrailIcon';
|
||||
import CustomSelect from '../shared/CustomSelect';
|
||||
import DawarichIcon from '../shared/DawarichIcon';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons';
|
||||
import EmptyState from '../shared/EmptyState';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { EditorField, INPUT, Segmented } from '../shared/dialogParts';
|
||||
import AddonSubRow from './AddonSubRow';
|
||||
import AddonTile from './AddonTile';
|
||||
|
||||
// Keys are the `icon` column from the addons table (see server seeds.ts); anything
|
||||
// unknown falls back to Puzzle. Users/Sparkles cover collab and llm_parsing, which
|
||||
// used to land on the fallback.
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles,
|
||||
ListChecks,
|
||||
Wallet,
|
||||
FileText,
|
||||
CalendarDays,
|
||||
Puzzle,
|
||||
Globe,
|
||||
Briefcase,
|
||||
Image,
|
||||
Terminal,
|
||||
Link2,
|
||||
Compass,
|
||||
BookOpen,
|
||||
Plane,
|
||||
Bookmark,
|
||||
Users,
|
||||
Sparkles,
|
||||
// Dawarich ships a brand mark rather than a lucide name, the same way the photo
|
||||
// providers below do. Keyed by the string in the addons table.
|
||||
Dawarich: DawarichIcon,
|
||||
};
|
||||
|
||||
function ImmichIcon({ size = 14 }: { size?: number }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
|
||||
<path
|
||||
d="M11.986.27c-2.409 0-5.207 1.09-5.207 3.894v.152c1.343.597 2.935 1.663 4.412 2.971 1.571 1.391 2.838 2.882 3.653 4.287 1.4-2.503 2.336-5.478 2.347-7.373V4.164c0-2.803-2.796-3.894-5.205-3.894m7.512 4.49c-.378-.008-.775.05-1.192.186l-.144.047c-.153 1.461-.676 3.304-1.463 5.113-.837 1.924-1.863 3.59-2.947 4.799 2.813.558 5.93.527 7.736-.047l.035-.01c2.667-.866 2.84-3.863 2.096-6.154-.628-1.933-2.081-3.89-4.121-3.934m-14.996.04c-2.04.043-3.493 1.997-4.121 3.93-.744 2.291-.571 5.288 2.096 6.155l.144.046c.982-1.092 2.488-2.276 4.188-3.277 1.809-1.065 3.619-1.808 5.207-2.148-1.949-2.105-4.489-3.914-6.287-4.51l-.036-.012c-.416-.135-.813-.193-1.191-.185m4.672 6.758c-2.604 1.202-5.109 3.06-6.233 4.586l-.021.029c-1.648 2.268-.027 4.795 1.922 6.211 1.949 1.416 4.852 2.177 6.5-.092.023-.031.054-.07.09-.121-.736-1.272-1.396-3.072-1.822-4.998-.454-2.05-.603-4-.436-5.615m1.072 3.338c.339 2.848 1.332 5.804 2.436 7.344l.021.029c1.648 2.268 4.551 1.508 6.5.092 1.949-1.416 3.57-3.943 1.922-6.211-.023-.031-.052-.073-.088-.123-1.437.307-3.352.38-5.316.19-2.089-.202-3.99-.663-5.475-1.321"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SynologyIcon({ size = 14 }: { size?: number }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
|
||||
<path
|
||||
d="M17.895 11.927a3.196 3.196 0 0 1 .394-1.53l-.008.017a2.677 2.677 0 0 1 1.075-1.108l.014-.007a3.181 3.181 0 0 1 1.523-.382h.05-.003q1.346 0 2.2.871.854.871.86 2.203c0 .895-.29 1.635-.867 2.226s-1.306.886-2.183.886c-.566 0-1.1-.137-1.571-.379l.019.009a2.535 2.535 0 0 1-1.115-1.067l-.007-.013q-.38-.708-.381-1.726zm1.593.083c0 .591.138 1.043.42 1.349a1.365 1.365 0 0 0 2.066.002l.001-.002c.275-.307.413-.764.413-1.357s-.138-1.033-.413-1.342a1.371 1.371 0 0 0-2.066-.001l-.001.002c-.281.306-.42.758-.42 1.345zm-1.602 2.941H16.33v-3.015c0-.635-.032-1.044-.101-1.234a.876.876 0 0 0-.328-.435l-.003-.002a.938.938 0 0 0-.521-.156h-.027.001-.012c-.27 0-.521.084-.727.228l.004-.003a1.115 1.115 0 0 0-.444.576l-.002.008c-.083.248-.121.696-.121 1.359v2.673H12.5V9.027h1.439v.867c.518-.656 1.167-.98 1.952-.98h.021c.335 0 .655.067.946.189l-.016-.006c.261.105.48.268.648.475l.002.003c.141.185.247.404.304.643l.002.012c.057.278.089.597.089.924l-.002.135v-.007zM6.413 9.028h1.654l1.412 4.204 1.376-4.204h1.611l-2.067 5.693-.38 1.038a4.158 4.158 0 0 1-.4.807l.01-.017a1.637 1.637 0 0 1-.422.443l-.005.003c-.17.113-.367.203-.578.26l-.014.003c-.232.064-.499.1-.774.1h-.025.001a4.13 4.13 0 0 1-.911-.105l.028.005-.129-1.229c.198.046.426.074.659.077h.002c.36 0 .628-.106.8-.318a2.27 2.27 0 0 0 .395-.807l.004-.016zM0 12.29l1.592-.149q.147.802.586 1.181.439.379 1.192.375c.528 0 .927-.113 1.197-.335.27-.222.4-.486.4-.782v-.024a.751.751 0 0 0-.167-.474l.001.001c-.113-.132-.309-.252-.59-.347-.193-.074-.631-.191-1.312-.365-.882-.216-1.496-.486-1.85-.804A2.147 2.147 0 0 1 .3 8.936v-.019V8.908c0-.431.132-.831.358-1.163l-.005.007a2.226 2.226 0 0 1 1.003-.826l.015-.005c.442-.184.973-.281 1.602-.281q1.529 0 2.304.676c.516.457.785 1.057.811 1.809l-1.649.055c-.073-.413-.219-.714-.452-.899-.233-.185-.579-.276-1.034-.276-.476 0-.85.098-1.118.298a.59.59 0 0 0-.261.49v.011-.001.002c0 .201.095.379.242.493l.001.001c.205.179.709.36 1.507.546.798.186 1.388.387 1.769.59.374.196.678.48.893.825l.006.01c.214.345.326.786.326 1.305 0 .489-.146.944-.396 1.325l.006-.009c-.264.408-.64.724-1.084.908l-.016.006c-.475.194-1.065.298-1.772.298-1.029 0-1.819-.241-2.373-.722-.554-.481-.879-1.177-.986-2.091z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const PROVIDER_ICONS: Record<string, ComponentType<{ size?: number }>> = {
|
||||
@@ -35,13 +96,31 @@ const PROVIDER_ICONS: Record<string, ComponentType<{ size?: number }>> = {
|
||||
// The document providers' own marks, single-colour so they read as glyphs in
|
||||
// a row of lucide icons and invert with the theme.
|
||||
...DOCUMENT_PROVIDER_ICONS,
|
||||
};
|
||||
|
||||
interface Addon {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ProviderOption {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
toggle: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface AddonIconProps {
|
||||
name: string
|
||||
size?: number
|
||||
name: string;
|
||||
size?: number;
|
||||
/** A switched-off addon greys its icon out, brand marks included. */
|
||||
enabled?: boolean
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
function AddonIcon({ name, size = 20, enabled = true }: AddonIconProps) {
|
||||
@@ -49,13 +128,13 @@ function AddonIcon({ name, size = 20, enabled = true }: AddonIconProps) {
|
||||
// sitting in the middle of it at glyph size, and they grey out when the addon
|
||||
// is off — the lucide glyphs get that from the slot's text colour, an <img>
|
||||
// has to be told.
|
||||
if (name === 'Dawarich') return <DawarichIcon fill muted={!enabled} />
|
||||
if (name === 'Dawarich') return <DawarichIcon fill muted={!enabled} />;
|
||||
// 'Plane' is the icon string airtrail was seeded with, and INSERT OR IGNORE
|
||||
// means every existing install still carries it — so the brand is keyed on
|
||||
// that rather than on a new name no row would ever have.
|
||||
if (name === 'Plane') return <AirTrailIcon fill muted={!enabled} />
|
||||
const Icon = ICON_MAP[name] || Puzzle
|
||||
return <Icon size={size} />
|
||||
if (name === 'Plane') return <AirTrailIcon fill muted={!enabled} />;
|
||||
const Icon = ICON_MAP[name] || Puzzle;
|
||||
return <Icon size={size} />;
|
||||
}
|
||||
|
||||
/** What each type means, shown on the tile itself now that the sections are gone. The
|
||||
@@ -64,14 +143,116 @@ const TYPE_META: Record<string, { icon: ComponentType<{ size?: number }>; labelK
|
||||
trip: { icon: Briefcase, labelKey: 'admin.addons.type.trip', hintKey: 'admin.addons.tripHint' },
|
||||
global: { icon: Globe, labelKey: 'admin.addons.type.global', hintKey: 'admin.addons.globalHint' },
|
||||
integration: { icon: Link2, labelKey: 'admin.addons.type.integration', hintKey: 'admin.addons.integrationHint' },
|
||||
};
|
||||
|
||||
interface CollabFeatures {
|
||||
chat: boolean;
|
||||
notes: boolean;
|
||||
links?: boolean;
|
||||
polls: boolean;
|
||||
whatsnext: boolean;
|
||||
}
|
||||
|
||||
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const dark = useIsDark()
|
||||
const {
|
||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
|
||||
} = useAddonManager()
|
||||
const COLLAB_SUB_FEATURES = [
|
||||
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
|
||||
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
|
||||
{ key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'admin.collab.links.subtitle' },
|
||||
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
|
||||
{
|
||||
key: 'whatsnext',
|
||||
icon: Sparkles,
|
||||
titleKey: 'admin.collab.whatsnext.title',
|
||||
subtitleKey: 'admin.collab.whatsnext.subtitle',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function AddonManager({
|
||||
bagTrackingEnabled,
|
||||
onToggleBagTracking,
|
||||
collabFeatures,
|
||||
onToggleCollabFeature,
|
||||
}: {
|
||||
bagTrackingEnabled?: boolean;
|
||||
onToggleBagTracking?: () => void;
|
||||
collabFeatures?: CollabFeatures;
|
||||
onToggleCollabFeature?: (key: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const dark = useIsDark();
|
||||
const toast = useToast();
|
||||
const refreshGlobalAddons = useAddonStore((s) => s.loadAddons);
|
||||
const [addons, setAddons] = useState<Addon[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAddons().finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const loadAddons = async () => {
|
||||
try {
|
||||
const data = await adminApi.addons();
|
||||
setAddons(data.addons);
|
||||
} catch (err: unknown) {
|
||||
toast.error(t('admin.addons.toast.error'));
|
||||
}
|
||||
};
|
||||
|
||||
/** Optimistic flip, per-row rollback on failure. Rolling the whole addons
|
||||
* snapshot back instead would undo a toggle the user hit in parallel. */
|
||||
const handleToggle = async (addon: Addon) => {
|
||||
const newEnabled = !addon.enabled;
|
||||
setAddons((prev) => prev.map((a) => (a.id === addon.id ? { ...a, enabled: newEnabled } : a)));
|
||||
try {
|
||||
await adminApi.updateAddon(addon.id, { enabled: newEnabled });
|
||||
} catch (err: unknown) {
|
||||
setAddons((prev) => prev.map((a) => (a.id === addon.id ? { ...a, enabled: !newEnabled } : a)));
|
||||
toast.error(t('admin.addons.toast.error'));
|
||||
return;
|
||||
}
|
||||
refreshGlobalAddons();
|
||||
// Journey off disables every photo provider with it, and the response carries
|
||||
// only Journey. Without re-reading, switching Journey back on brings the shelf
|
||||
// up with providers the database has long since turned off.
|
||||
// Same for Documents: switching it off disables every document provider in
|
||||
// the database, and the response carries only the addon itself.
|
||||
if (addon.id === 'journey' || addon.id === 'documents') await loadAddons();
|
||||
toast.success(t('admin.addons.toast.updated'));
|
||||
};
|
||||
|
||||
const isPhotoProviderAddon = (addon: Addon) => {
|
||||
return addon.type === 'photo_provider';
|
||||
};
|
||||
|
||||
const isDocumentProviderAddon = (addon: Addon) => {
|
||||
return addon.type === 'document_provider';
|
||||
};
|
||||
|
||||
const isPhotosAddon = (addon: Addon) => {
|
||||
const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase();
|
||||
return (
|
||||
addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
|
||||
);
|
||||
};
|
||||
|
||||
const photoProviderAddons = addons.filter(isPhotoProviderAddon);
|
||||
const documentProviderAddons = addons.filter(isDocumentProviderAddon);
|
||||
const tripAddons = addons.filter((a) => a.type === 'trip' && !isPhotosAddon(a));
|
||||
const globalAddons = addons.filter((a) => a.type === 'global');
|
||||
const integrationAddons = addons.filter((a) => a.type === 'integration');
|
||||
const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({
|
||||
key: provider.id,
|
||||
label: provider.name,
|
||||
description: provider.description,
|
||||
enabled: provider.enabled,
|
||||
toggle: () => handleToggle(provider),
|
||||
}));
|
||||
const documentProviderOptions: ProviderOption[] = documentProviderAddons.map((provider) => ({
|
||||
key: provider.id,
|
||||
label: provider.name,
|
||||
description: provider.description,
|
||||
enabled: provider.enabled,
|
||||
toggle: () => handleToggle(provider),
|
||||
}));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -80,7 +261,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
<Loader2 size={22} className="animate-spin text-content-faint" />
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Bag tracking, the collab features and the photo providers all hang off their
|
||||
@@ -96,11 +277,11 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
enabled={!!bagTrackingEnabled}
|
||||
onToggle={onToggleBagTracking}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
if (addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature) {
|
||||
return COLLAB_SUB_FEATURES.map(feat => {
|
||||
const Icon = feat.icon
|
||||
return COLLAB_SUB_FEATURES.map((feat) => {
|
||||
const Icon = feat.icon;
|
||||
return (
|
||||
<AddonSubRow
|
||||
key={feat.key}
|
||||
@@ -110,16 +291,16 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
enabled={collabFeatures[feat.key]}
|
||||
onToggle={() => onToggleCollabFeature(feat.key)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
// Document providers are the Documents tile's shelf, the way photo providers
|
||||
// are Journey's. Unlike those they carry no credential form here: a document
|
||||
// connection belongs to a trip, not to a user, so it is entered in the trip's
|
||||
// file manager. The admin decides only whether a provider may be offered.
|
||||
if (addon.id === 'documents' && addon.enabled && documentProviderOptions.length > 0) {
|
||||
return documentProviderOptions.map(provider => {
|
||||
const ProviderIcon = PROVIDER_ICONS[provider.key]
|
||||
return documentProviderOptions.map((provider) => {
|
||||
const ProviderIcon = PROVIDER_ICONS[provider.key];
|
||||
return (
|
||||
<AddonSubRow
|
||||
key={provider.key}
|
||||
@@ -129,12 +310,12 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
enabled={provider.enabled}
|
||||
onToggle={provider.toggle}
|
||||
/>
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
if (addon.id === 'journey' && addon.enabled && providerOptions.length > 0) {
|
||||
return providerOptions.map(provider => {
|
||||
const ProviderIcon = PROVIDER_ICONS[provider.key]
|
||||
return providerOptions.map((provider) => {
|
||||
const ProviderIcon = PROVIDER_ICONS[provider.key];
|
||||
return (
|
||||
<AddonSubRow
|
||||
key={provider.key}
|
||||
@@ -144,19 +325,19 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
enabled={provider.enabled}
|
||||
onToggle={provider.toggle}
|
||||
/>
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
// The AI-parsing settings hang off their tile like every other sub-feature,
|
||||
// just as a form instead of toggle rows.
|
||||
if (addon.id === 'llm_parsing' && addon.enabled) {
|
||||
return <LlmParsingConfig addon={addon} />
|
||||
return <LlmParsingConfig addon={addon} />;
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const tile = (addon: Addon) => {
|
||||
const label = getAddonLabel(t, addon)
|
||||
const label = getAddonLabel(t, addon);
|
||||
return (
|
||||
<AddonTile
|
||||
key={addon.id}
|
||||
@@ -168,8 +349,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
>
|
||||
{shelfFor(addon)}
|
||||
</AddonTile>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/* One column per type, side by side, instead of three stacked sections. Stacked, each
|
||||
section opened its own set of columns and the tall tiles (Collab, Journey) set a
|
||||
@@ -179,59 +360,78 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
{ key: 'trip', addons: tripAddons },
|
||||
{ key: 'global', addons: globalAddons },
|
||||
{ key: 'integration', addons: integrationAddons },
|
||||
].filter(g => g.addons.length > 0)
|
||||
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length
|
||||
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
|
||||
].filter((g) => g.addons.length > 0);
|
||||
const enabledCount = addons.filter(
|
||||
(a) => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled
|
||||
).length;
|
||||
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length;
|
||||
|
||||
const subtitle = (
|
||||
<span className="inline-flex flex-wrap items-center gap-1">
|
||||
{t('admin.addons.subtitleBefore')}
|
||||
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, verticalAlign: 'middle', opacity: 0.7 }} />
|
||||
<img
|
||||
src={dark ? '/text-light.svg' : '/text-dark.svg'}
|
||||
alt="TREK"
|
||||
style={{ height: 10, verticalAlign: 'middle', opacity: 0.7 }}
|
||||
/>
|
||||
{t('admin.addons.subtitleAfter')}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
icon={Puzzle}
|
||||
title={t('admin.addons.title')}
|
||||
hint={subtitle}
|
||||
badge={totalCount > 0 ? (
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
badge={
|
||||
totalCount > 0 ? (
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>
|
||||
{enabledCount}/{totalCount}
|
||||
</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{addons.length === 0 ? (
|
||||
<EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{groups.map(g => {
|
||||
const meta = TYPE_META[g.key]
|
||||
{groups.map((g) => {
|
||||
const meta = TYPE_META[g.key];
|
||||
return (
|
||||
<section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
|
||||
<section
|
||||
key={g.key}
|
||||
className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card"
|
||||
>
|
||||
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.addons} t={t} />
|
||||
<div className="divide-y divide-edge-faint">{g.addons.map(tile)}</div>
|
||||
</section>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** The head band of one type's column: icon tile, the type, its count and what it means. */
|
||||
function GroupHead({ icon: Icon, label, hint, addons, t }: {
|
||||
icon: ComponentType<{ size?: number; className?: string; strokeWidth?: number }>
|
||||
label: string
|
||||
hint: string
|
||||
addons: Addon[]
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
function GroupHead({
|
||||
icon: Icon,
|
||||
label,
|
||||
hint,
|
||||
addons,
|
||||
t,
|
||||
}: {
|
||||
icon: ComponentType<{ size?: number; className?: string; strokeWidth?: number }>;
|
||||
label: string;
|
||||
hint: string;
|
||||
addons: Addon[];
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
const enabled = addons.filter(a => a.enabled).length
|
||||
const enabled = addons.filter((a) => a.enabled).length;
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 border-b border-edge-faint bg-surface-tertiary px-3.5 py-2.5">
|
||||
<span className="mt-px grid h-7 w-7 flex-none place-items-center rounded-[9px] bg-surface-card text-content-secondary shadow-sm">
|
||||
@@ -239,22 +439,44 @@ function GroupHead({ icon: Icon, label, hint, addons, t }: {
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 min-w-0 flex-1 truncate font-bold text-content" style={fs(13, 'body')}>{label}</h3>
|
||||
<h3 className="m-0 min-w-0 flex-1 truncate font-bold text-content" style={fs(13, 'body')}>
|
||||
{label}
|
||||
</h3>
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled, total: addons.length })}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill>{enabled}/{addons.length}</StatusPill>
|
||||
<StatusPill>
|
||||
{enabled}/{addons.length}
|
||||
</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* Says what the type means: the only place that still explains it. */}
|
||||
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11)}>{hint}</p>
|
||||
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11)}>
|
||||
{hint}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
||||
const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py-1 font-medium transition-colors disabled:cursor-default disabled:opacity-60'
|
||||
const MASKED = '••••••••';
|
||||
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint';
|
||||
const SMALL_BUTTON =
|
||||
'inline-flex flex-none items-center rounded-[9px] px-2.5 py-1 font-medium transition-colors disabled:cursor-default disabled:opacity-60';
|
||||
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.5:4b',
|
||||
label: 'Qwen3.5 — 4B',
|
||||
note: 'Recommended · small and quick on CPU, 3.4 GB download, 256K context (thinking auto-disabled) · Apache-2.0',
|
||||
recommended: true,
|
||||
vision: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Instance-wide AI-parsing config. When set, applies to the whole instance and
|
||||
@@ -263,19 +485,99 @@ const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py
|
||||
* provider, it also lists installed Ollama models and can pull NuExtract models.
|
||||
*/
|
||||
function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
provider, setProvider, model, setModel, baseUrl, setBaseUrl, apiKey, setApiKey, vision, setVision, saving,
|
||||
installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save,
|
||||
} = useLlmParsingConfig(addon)
|
||||
const fieldId = useId()
|
||||
const { t } = useTranslation();
|
||||
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 [vision, setVision] = useState<LlmVision>(asLlmVision(cfg.vision));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const fieldId = useId();
|
||||
|
||||
// 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') void 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: provider === 'anthropic' ? '' : baseUrl.trim(),
|
||||
apiKey,
|
||||
vision,
|
||||
},
|
||||
});
|
||||
toast.success('Saved');
|
||||
} catch {
|
||||
toast.error('Failed to save');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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} /> },
|
||||
]
|
||||
const visionOptions = LLM_VISION_MODES.map(value => ({ value, label: t(`admin.addons.llm.vision.${value}`) }))
|
||||
];
|
||||
const visionOptions = LLM_VISION_MODES.map((value) => ({ value, label: t(`admin.addons.llm.vision.${value}`) }));
|
||||
|
||||
/* Lives in the tile's shelf like the collab toggles, so it is one column of
|
||||
eyebrow fields: the band this used to be had a whole page width. */
|
||||
@@ -286,29 +588,61 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
</SettingsHint>
|
||||
|
||||
<EditorField label="Provider">
|
||||
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
|
||||
<CustomSelect value={provider} onChange={(v) => setProvider(String(v))} options={providerOptions} />
|
||||
</EditorField>
|
||||
{provider !== 'anthropic' && (
|
||||
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
|
||||
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
|
||||
<input
|
||||
id={`${fieldId}-url`}
|
||||
type="url"
|
||||
autoComplete="off"
|
||||
className={INPUT}
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
onBlur={loadModels}
|
||||
placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'}
|
||||
/>
|
||||
</EditorField>
|
||||
)}
|
||||
<EditorField
|
||||
label="API key"
|
||||
htmlFor={`${fieldId}-key`}
|
||||
hint={provider === 'anthropic' ? 'Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.' : undefined}
|
||||
hint={
|
||||
provider === 'anthropic'
|
||||
? 'Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<input id={`${fieldId}-key`} type="password" className={INPUT} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
|
||||
<input
|
||||
id={`${fieldId}-key`}
|
||||
type="password"
|
||||
className={INPUT}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'}
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField label="Model" htmlFor={`${fieldId}-model`}>
|
||||
<input id={`${fieldId}-model`} autoComplete="off" className={`${INPUT} font-geist`} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
||||
<input
|
||||
id={`${fieldId}-model`}
|
||||
autoComplete="off"
|
||||
className={`${INPUT} font-geist`}
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder={
|
||||
provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'
|
||||
}
|
||||
/>
|
||||
</EditorField>
|
||||
|
||||
<EditorField label={t('settings.aiParsing.multimodal')} hint={t(provider === 'local' ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}>
|
||||
<EditorField
|
||||
label={t('settings.aiParsing.multimodal')}
|
||||
hint={t(provider === 'local' ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}
|
||||
>
|
||||
<Segmented<LlmVision>
|
||||
label={t('settings.aiParsing.multimodal')}
|
||||
value={vision}
|
||||
onChange={v => setVision(asLlmVision(v))}
|
||||
onChange={(v) => setVision(asLlmVision(v))}
|
||||
options={visionOptions}
|
||||
fill
|
||||
/>
|
||||
@@ -318,22 +652,33 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
{provider === 'local' && (
|
||||
<div className="flex flex-col gap-2.5 rounded-[12px] border border-edge-faint bg-surface-card p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`${EYEBROW} min-w-0 flex-1 truncate`} style={fs(9.5)}>Installed on the server</span>
|
||||
<button type="button" onClick={loadModels} disabled={loadingModels}
|
||||
<span className={`${EYEBROW} min-w-0 flex-1 truncate`} style={fs(9.5)}>
|
||||
Installed on the server
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadModels}
|
||||
disabled={loadingModels}
|
||||
className="inline-flex flex-none items-center gap-1 rounded-full px-2 py-0.5 font-medium text-content-muted hover:bg-surface-secondary hover:text-content disabled:cursor-default disabled:opacity-60"
|
||||
style={fs(11.5, 'body')}>
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
<RefreshCw size={11} strokeWidth={2.2} className={loadingModels ? 'animate-spin' : undefined} />
|
||||
{loadingModels ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
{modelsErr && <p className="m-0 break-words text-danger" style={fs(11.5)}>{modelsErr}</p>}
|
||||
{modelsErr && (
|
||||
<p className="m-0 break-words text-danger" style={fs(11.5)}>
|
||||
{modelsErr}
|
||||
</p>
|
||||
)}
|
||||
{!modelsErr && installed.length === 0 && !loadingModels && (
|
||||
<SettingsHint>No models installed yet — pull one below.</SettingsHint>
|
||||
)}
|
||||
{installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{installed.map(name => (
|
||||
<button type="button"
|
||||
{installed.map((name) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => setModel(name)}
|
||||
@@ -348,22 +693,38 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-edge-faint pt-2.5">
|
||||
<span className={EYEBROW} style={fs(9.5)}>Pull a recommended model</span>
|
||||
{RECOMMENDED_MODELS.map(m => {
|
||||
const installedHere = isInstalled(m.id)
|
||||
const isPulling = pulling === m.id
|
||||
const active = model === m.id
|
||||
<span className={EYEBROW} style={fs(9.5)}>
|
||||
Pull a recommended model
|
||||
</span>
|
||||
{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="min-w-0" title={m.note}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>{m.label}</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
|
||||
{m.label}
|
||||
</span>
|
||||
{m.recommended && <StatusPill tone="success">Recommended</StatusPill>}
|
||||
{installedHere ? (
|
||||
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`${SMALL_BUTTON} ${active ? 'bg-surface-tertiary text-content-muted' : 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary'}`} style={fs(12, 'body')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModel(m.id)}
|
||||
disabled={active}
|
||||
className={`${SMALL_BUTTON} ${active ? 'bg-surface-tertiary text-content-muted' : 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary'}`}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{active ? 'Selected' : 'Use'}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className={`${SMALL_BUTTON} bg-accent text-accent-text hover:opacity-90`} style={fs(12, 'body')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pull(m.id)}
|
||||
disabled={!!pulling}
|
||||
className={`${SMALL_BUTTON} bg-accent text-accent-text hover:opacity-90`}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{isPulling ? 'Pulling…' : 'Pull'}
|
||||
</button>
|
||||
)}
|
||||
@@ -371,23 +732,47 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
{isPulling && (
|
||||
<div className="mt-2">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div className="h-full rounded-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
|
||||
<div
|
||||
className="h-full rounded-full bg-accent transition-[width] duration-200"
|
||||
style={{ width: `${pullPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>
|
||||
{pullStatus}
|
||||
{pullPct ? ` · ${pullPct}%` : ''}
|
||||
</div>
|
||||
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={save} disabled={saving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
|
||||
const nameKey = `admin.addons.catalog.${addon.id}.name`;
|
||||
const descKey = `admin.addons.catalog.${addon.id}.description`;
|
||||
const translatedName = t(nameKey);
|
||||
const translatedDescription = t(descKey);
|
||||
|
||||
return {
|
||||
name: translatedName !== nameKey ? translatedName : addon.name,
|
||||
description: translatedDescription !== descKey ? translatedDescription : addon.description,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import type { ReactNode } from 'react';
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
|
||||
/**
|
||||
* One child row inside an {@link AddonTile}'s sub-shelf: bag tracking under
|
||||
@@ -23,21 +23,31 @@ export default function AddonSubRow({
|
||||
onToggle,
|
||||
}: {
|
||||
/** Vendor mark or lucide glyph at 14px. Omit it and the slot keeps its width. */
|
||||
icon?: ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
enabled: boolean
|
||||
onToggle: () => void
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<li className="flex min-h-[44px] items-center gap-2.5 px-3 py-1.5">
|
||||
{icon
|
||||
? <span className={`grid h-6 w-6 flex-none place-items-center ${enabled ? 'text-content-muted' : 'text-content-faint'}`}>{icon}</span>
|
||||
: <span className="w-6 flex-none" aria-hidden />}
|
||||
<span className={`min-w-0 flex-1 truncate font-medium ${enabled ? 'text-content' : 'text-content-muted'}`} style={fs(12.5, 'body')} title={description}>
|
||||
{icon ? (
|
||||
<span
|
||||
className={`grid h-6 w-6 flex-none place-items-center ${enabled ? 'text-content-muted' : 'text-content-faint'}`}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-6 flex-none" aria-hidden />
|
||||
)}
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate font-medium ${enabled ? 'text-content' : 'text-content-muted'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
title={description}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<ToggleSwitch on={enabled} onToggle={onToggle} label={title} />
|
||||
</li>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import type { ReactNode } from 'react';
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
|
||||
/**
|
||||
* One addon in its type's card: a row like the bag sidebar's (#2541), with the
|
||||
@@ -23,13 +23,13 @@ export default function AddonTile({
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
name: string
|
||||
description?: string
|
||||
enabled: boolean
|
||||
onToggle: () => void
|
||||
icon: ReactNode;
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
/** The sub-shelf: `<AddonSubRow>` children. */
|
||||
children?: ReactNode
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
|
||||
@@ -38,7 +38,9 @@ export default function AddonTile({
|
||||
// overflow-hidden so a brand mark that fills the slot keeps the slot's
|
||||
// rounded corners instead of squaring them off.
|
||||
className={`grid h-9 w-9 flex-none place-items-center overflow-hidden rounded-[10px] transition-colors ${
|
||||
enabled ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'bg-surface-tertiary text-content-faint'
|
||||
enabled
|
||||
? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint'
|
||||
: 'bg-surface-tertiary text-content-faint'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
@@ -70,5 +72,5 @@ export default function AddonTile({
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// FE-ADMIN-MCP-001 to FE-ADMIN-MCP-016
|
||||
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';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import AdminMcpTokensPanel from './AdminMcpTokensPanel';
|
||||
@@ -39,7 +39,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
it('FE-ADMIN-MCP-001: loading spinner shown on mount', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
return HttpResponse.json({ tokens: [] });
|
||||
})
|
||||
);
|
||||
@@ -53,11 +53,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-003: token list renders correctly', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||
)
|
||||
);
|
||||
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('CI Token');
|
||||
expect(screen.getByText('Ops Token')).toBeInTheDocument();
|
||||
@@ -69,11 +65,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-004: "Never" shown when last_used_at is null', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||
)
|
||||
);
|
||||
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('CI Token');
|
||||
expect(screen.getByText('Never')).toBeInTheDocument();
|
||||
@@ -81,11 +73,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
|
||||
it('FE-ADMIN-MCP-005: delete confirmation dialog opens', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||
)
|
||||
);
|
||||
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
@@ -100,11 +88,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
|
||||
it('FE-ADMIN-MCP-006: cancel closes confirmation dialog without deleting', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||
)
|
||||
);
|
||||
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
@@ -121,11 +105,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
|
||||
it('FE-ADMIN-MCP-007: backdrop click closes dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||
)
|
||||
);
|
||||
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
@@ -145,14 +125,15 @@ describe('AdminMcpTokensPanel', () => {
|
||||
it('FE-ADMIN-MCP-008: successful delete removes token from list', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||
),
|
||||
http.delete('/api/admin/mcp-tokens/:id', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })),
|
||||
http.delete('/api/admin/mcp-tokens/:id', () => HttpResponse.json({ success: true }))
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AdminMcpTokensPanel />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
const deleteButtons = screen.getAllByLabelText('Delete');
|
||||
@@ -170,14 +151,15 @@ describe('AdminMcpTokensPanel', () => {
|
||||
it('FE-ADMIN-MCP-009: failed delete shows error toast and keeps list unchanged', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||
),
|
||||
http.delete('/api/admin/mcp-tokens/:id', () =>
|
||||
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
)
|
||||
http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })),
|
||||
http.delete('/api/admin/mcp-tokens/:id', () => HttpResponse.json({ error: 'forbidden' }, { status: 403 }))
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AdminMcpTokensPanel />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
const deleteButtons = screen.getAllByLabelText('Delete');
|
||||
@@ -189,19 +171,20 @@ describe('AdminMcpTokensPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-010: load failure shows error toast', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ error: 'server error' }, { status: 500 })
|
||||
)
|
||||
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ error: 'server error' }, { status: 500 })));
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AdminMcpTokensPanel />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
expect(await screen.findByText('Failed to load tokens')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
return HttpResponse.json({ sessions: [] });
|
||||
})
|
||||
);
|
||||
@@ -210,11 +193,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-012: OAuth sessions empty state rendered when no sessions', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({ sessions: [] })
|
||||
)
|
||||
);
|
||||
server.use(http.get('/api/admin/oauth-sessions', () => HttpResponse.json({ sessions: [] })));
|
||||
render(<AdminMcpTokensPanel />);
|
||||
expect(await screen.findByText('No active OAuth sessions')).toBeInTheDocument();
|
||||
});
|
||||
@@ -244,13 +223,19 @@ describe('AdminMcpTokensPanel', () => {
|
||||
it('FE-ADMIN-MCP-014: scope expand/collapse toggle shows hidden scopes', async () => {
|
||||
const user = userEvent.setup();
|
||||
// 7 scopes — more than SCOPES_PREVIEW=6, so "+1 more" button appears
|
||||
const scopes = ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read'];
|
||||
const scopes = [
|
||||
'trips:read',
|
||||
'trips:write',
|
||||
'places:read',
|
||||
'places:write',
|
||||
'budget:read',
|
||||
'budget:write',
|
||||
'packing:read',
|
||||
];
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [
|
||||
{ id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' },
|
||||
],
|
||||
sessions: [{ id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' }],
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -270,15 +255,24 @@ describe('AdminMcpTokensPanel', () => {
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [
|
||||
{ id: 5, client_name: 'Revoke Me', username: 'carol', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
|
||||
{
|
||||
id: 5,
|
||||
client_name: 'Revoke Me',
|
||||
username: 'carol',
|
||||
scopes: ['trips:read'],
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.delete('/api/admin/oauth-sessions/5', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
http.delete('/api/admin/oauth-sessions/5', () => HttpResponse.json({ success: true }))
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AdminMcpTokensPanel />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('Revoke Me');
|
||||
|
||||
// Click the revoke (trash) button next to the session
|
||||
@@ -289,7 +283,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
expect(screen.getByText('Revoke Session')).toBeInTheDocument();
|
||||
// Confirm — find the modal's Delete button (has no aria-label, unlike the trash icon)
|
||||
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
|
||||
const confirmBtn = deleteBtns.find(b => !b.hasAttribute('aria-label'));
|
||||
const confirmBtn = deleteBtns.find((b) => !b.hasAttribute('aria-label'));
|
||||
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Revoke Me')).not.toBeInTheDocument();
|
||||
@@ -302,21 +296,30 @@ describe('AdminMcpTokensPanel', () => {
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [
|
||||
{ id: 6, client_name: 'Error Session', username: 'dave', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
|
||||
{
|
||||
id: 6,
|
||||
client_name: 'Error Session',
|
||||
username: 'dave',
|
||||
scopes: ['trips:read'],
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.delete('/api/admin/oauth-sessions/6', () =>
|
||||
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
)
|
||||
http.delete('/api/admin/oauth-sessions/6', () => HttpResponse.json({ error: 'forbidden' }, { status: 403 }))
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AdminMcpTokensPanel />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('Error Session');
|
||||
|
||||
const deleteBtn = screen.getAllByLabelText('Delete')[0];
|
||||
await user.click(deleteBtn);
|
||||
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
|
||||
const confirmBtn = deleteBtns.find(b => !b.hasAttribute('aria-label'));
|
||||
const confirmBtn = deleteBtns.find((b) => !b.hasAttribute('aria-label'));
|
||||
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
|
||||
expect(await screen.findByText('Failed to revoke session')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,15 +1,41 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { Key, Trash2, User, Loader2, Shield, KeyRound, Bot } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { SETTINGS_ICON_BUTTON, SettingRows, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit'
|
||||
import { SCOPES_PREVIEW, useMcpTokensAdmin } from './useMcpTokensAdmin'
|
||||
import { Bot, Key, KeyRound, Loader2, Shield, Trash2, User } from 'lucide-react';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { adminApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { SETTINGS_ICON_BUTTON, SettingRows, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit';
|
||||
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
|
||||
const ROW = 'flex flex-wrap items-center gap-x-4 gap-y-2 px-3.5 py-3'
|
||||
const TILE = 'grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary'
|
||||
const CHIP = 'inline-flex items-center rounded-full border border-edge-faint bg-surface-secondary px-2 py-[1px] font-geist text-content-muted'
|
||||
interface AdminOAuthSession {
|
||||
id: number;
|
||||
client_id: string;
|
||||
client_name: string;
|
||||
user_id: number;
|
||||
username: string;
|
||||
scopes: string[];
|
||||
access_token_expires_at: string;
|
||||
refresh_token_expires_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AdminMcpToken {
|
||||
id: number;
|
||||
name: string;
|
||||
token_prefix: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
user_id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
const SCOPES_PREVIEW = 6;
|
||||
|
||||
const ROW = 'flex flex-wrap items-center gap-x-4 gap-y-2 px-3.5 py-3';
|
||||
const TILE = 'grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary';
|
||||
const CHIP =
|
||||
'inline-flex items-center rounded-full border border-edge-faint bg-surface-secondary px-2 py-[1px] font-geist text-content-muted';
|
||||
|
||||
/** The spinner or the empty line a list shows in place of its rows. */
|
||||
function ListState({ loading, icon, text }: { loading: boolean; icon: ReactNode; text: string }) {
|
||||
@@ -18,55 +44,114 @@ function ListState({ loading, icon, text }: { loading: boolean; icon: ReactNode;
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 size={18} className="animate-spin text-content-faint" />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-content-faint">
|
||||
{icon}
|
||||
<SettingsHint>{text}</SettingsHint>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** One fact on the right of a row: an eyebrow over its value. */
|
||||
function Meta({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0 text-end">
|
||||
<div className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>{label}</div>
|
||||
<div className="whitespace-nowrap font-geist tabular-nums text-content-muted" style={fs(12, 'body')}>{children}</div>
|
||||
<div className="min-w-0 text-right">
|
||||
<div className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>
|
||||
{label}
|
||||
</div>
|
||||
<div className="whitespace-nowrap font-geist tabular-nums text-content-muted" style={fs(12, 'body')}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** The owner of a session or token, with the person glyph in front. */
|
||||
function Owner({ name }: { name: string }) {
|
||||
return (
|
||||
<span className="inline-flex min-w-0 max-w-[180px] items-center gap-1.5 text-content-secondary" style={fs(12.5, 'body')}>
|
||||
<span
|
||||
className="inline-flex min-w-0 max-w-[180px] items-center gap-1.5 text-content-secondary"
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<User size={13} className="flex-none text-content-faint" />
|
||||
<span className="truncate">{name}</span>
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** The trash button of a row, named by its tooltip. */
|
||||
function DeleteAction({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<Tooltip label={label} placement="left">
|
||||
<button type="button" onClick={onClick} aria-label={label} className={`${SETTINGS_ICON_BUTTON} hover:text-danger`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
className={`${SETTINGS_ICON_BUTTON} hover:text-danger`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminMcpTokensPanel() {
|
||||
const {
|
||||
sessions, sessionsLoading, tokens, tokensLoading, expandedScopes, toggleScopes,
|
||||
revokeConfirmId, setRevokeConfirmId, deleteConfirmId, setDeleteConfirmId, handleRevoke, handleDelete,
|
||||
} = useMcpTokensAdmin()
|
||||
const { t, locale } = useTranslation()
|
||||
const [sessions, setSessions] = useState<AdminOAuthSession[]>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true);
|
||||
const [tokens, setTokens] = useState<AdminMcpToken[]>([]);
|
||||
const [tokensLoading, setTokensLoading] = useState(true);
|
||||
const [expandedScopes, setExpandedScopes] = useState<Set<number>>(new Set());
|
||||
const [revokeConfirmId, setRevokeConfirmId] = useState<number | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null);
|
||||
|
||||
const date = (iso: string) => new Date(iso).toLocaleDateString(locale)
|
||||
const toggleScopes = (id: number) =>
|
||||
setExpandedScopes((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
const toast = useToast();
|
||||
const { t, locale } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
adminApi
|
||||
.oauthSessions()
|
||||
.then((d) => setSessions(d.sessions || []))
|
||||
.catch(() => toast.error(t('admin.oauthSessions.loadError')))
|
||||
.finally(() => setSessionsLoading(false));
|
||||
|
||||
adminApi
|
||||
.mcpTokens()
|
||||
.then((d) => setTokens(d.tokens || []))
|
||||
.catch(() => toast.error(t('admin.mcpTokens.loadError')))
|
||||
.finally(() => setTokensLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleRevoke = async (id: number) => {
|
||||
try {
|
||||
await adminApi.revokeOAuthSession(id);
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
setRevokeConfirmId(null);
|
||||
toast.success(t('admin.oauthSessions.revokeSuccess'));
|
||||
} catch {
|
||||
toast.error(t('admin.oauthSessions.revokeError'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await adminApi.deleteMcpToken(id);
|
||||
setTokens((prev) => prev.filter((tk) => tk.id !== id));
|
||||
setDeleteConfirmId(null);
|
||||
toast.success(t('admin.mcpTokens.deleteSuccess'));
|
||||
} catch {
|
||||
toast.error(t('admin.mcpTokens.deleteError'));
|
||||
}
|
||||
};
|
||||
|
||||
const date = (iso: string) => new Date(iso).toLocaleDateString(locale);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -79,25 +164,38 @@ export default function AdminMcpTokensPanel() {
|
||||
badge={!sessionsLoading && sessions.length > 0 ? <StatusPill>{sessions.length}</StatusPill> : undefined}
|
||||
>
|
||||
{sessionsLoading || sessions.length === 0 ? (
|
||||
<ListState loading={sessionsLoading} icon={<Shield size={24} strokeWidth={1.6} />} text={t('admin.oauthSessions.empty')} />
|
||||
<ListState
|
||||
loading={sessionsLoading}
|
||||
icon={<Shield size={24} strokeWidth={1.6} />}
|
||||
text={t('admin.oauthSessions.empty')}
|
||||
/>
|
||||
) : (
|
||||
<SettingRows>
|
||||
{sessions.map(session => {
|
||||
const expanded = expandedScopes.has(session.id)
|
||||
const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW)
|
||||
const hidden = session.scopes.length - SCOPES_PREVIEW
|
||||
{sessions.map((session) => {
|
||||
const expanded = expandedScopes.has(session.id);
|
||||
const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW);
|
||||
const hidden = session.scopes.length - SCOPES_PREVIEW;
|
||||
return (
|
||||
<div key={session.id} className={`${ROW} items-start`}>
|
||||
<span className={TILE}><Bot size={16} strokeWidth={1.9} /></span>
|
||||
<span className={TILE}>
|
||||
<Bot size={16} strokeWidth={1.9} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 basis-60">
|
||||
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{session.client_name}</p>
|
||||
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{session.client_name}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1" style={fs(11)}>
|
||||
{visible.map(scope => (
|
||||
<span key={scope} className={CHIP}>{scope}</span>
|
||||
{visible.map((scope) => (
|
||||
<span key={scope} className={CHIP}>
|
||||
{scope}
|
||||
</span>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button type="button" onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center rounded-full bg-surface-tertiary px-2 py-[1px] font-semibold text-content-secondary hover:text-content">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center rounded-full bg-surface-tertiary px-2 py-[1px] font-semibold text-content-secondary hover:text-content"
|
||||
>
|
||||
{expanded ? 'show less' : `+${hidden} more`}
|
||||
</button>
|
||||
)}
|
||||
@@ -109,7 +207,7 @@ export default function AdminMcpTokensPanel() {
|
||||
<DeleteAction label={t('common.delete')} onClick={() => setRevokeConfirmId(session.id)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</SettingRows>
|
||||
)}
|
||||
@@ -122,15 +220,25 @@ export default function AdminMcpTokensPanel() {
|
||||
badge={!tokensLoading && tokens.length > 0 ? <StatusPill>{tokens.length}</StatusPill> : undefined}
|
||||
>
|
||||
{tokensLoading || tokens.length === 0 ? (
|
||||
<ListState loading={tokensLoading} icon={<Key size={24} strokeWidth={1.6} />} text={t('admin.mcpTokens.empty')} />
|
||||
<ListState
|
||||
loading={tokensLoading}
|
||||
icon={<Key size={24} strokeWidth={1.6} />}
|
||||
text={t('admin.mcpTokens.empty')}
|
||||
/>
|
||||
) : (
|
||||
<SettingRows>
|
||||
{tokens.map(token => (
|
||||
{tokens.map((token) => (
|
||||
<div key={token.id} className={ROW}>
|
||||
<span className={TILE}><KeyRound size={16} strokeWidth={1.9} /></span>
|
||||
<span className={TILE}>
|
||||
<KeyRound size={16} strokeWidth={1.9} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 basis-48">
|
||||
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{token.name}</p>
|
||||
<p className="m-0 mt-0.5 truncate font-geist text-content-faint" style={fs(11)}>{token.token_prefix}...</p>
|
||||
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{token.name}
|
||||
</p>
|
||||
<p className="m-0 mt-0.5 truncate font-geist text-content-faint" style={fs(11)}>
|
||||
{token.token_prefix}...
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-none items-center gap-4">
|
||||
<Owner name={token.username} />
|
||||
@@ -150,7 +258,9 @@ export default function AdminMcpTokensPanel() {
|
||||
<ConfirmDialog
|
||||
isOpen={revokeConfirmId !== null}
|
||||
onClose={() => setRevokeConfirmId(null)}
|
||||
onConfirm={() => { if (revokeConfirmId !== null) void handleRevoke(revokeConfirmId) }}
|
||||
onConfirm={() => {
|
||||
if (revokeConfirmId !== null) void handleRevoke(revokeConfirmId);
|
||||
}}
|
||||
title={t('admin.oauthSessions.revokeTitle')}
|
||||
message={t('admin.oauthSessions.revokeMessage')}
|
||||
confirmLabel={t('common.delete')}
|
||||
@@ -161,12 +271,14 @@ export default function AdminMcpTokensPanel() {
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirmId !== null}
|
||||
onClose={() => setDeleteConfirmId(null)}
|
||||
onConfirm={() => { if (deleteConfirmId !== null) void handleDelete(deleteConfirmId) }}
|
||||
onConfirm={() => {
|
||||
if (deleteConfirmId !== null) void handleDelete(deleteConfirmId);
|
||||
}}
|
||||
title={t('admin.mcpTokens.deleteTitle')}
|
||||
message={t('admin.mcpTokens.deleteMessage')}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
// FE-ADMIN-AUDIT-001 to FE-ADMIN-AUDIT-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';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import AuditLogPanel from './AuditLogPanel';
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('AuditLogPanel', () => {
|
||||
http.get('/api/admin/audit-log', async () => {
|
||||
await new Promise(() => {}); // never resolves
|
||||
return HttpResponse.json({ entries: [], total: 0 });
|
||||
}),
|
||||
})
|
||||
);
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
@@ -52,22 +52,14 @@ describe('AuditLogPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-AUDIT-002: empty state shown when no entries', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () =>
|
||||
HttpResponse.json({ entries: [], total: 0 }),
|
||||
),
|
||||
);
|
||||
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [], total: 0 })));
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
await screen.findByText('No audit entries yet.');
|
||||
expect(document.querySelector('table')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-AUDIT-003: table renders all columns with data', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () =>
|
||||
HttpResponse.json({ entries: [ENTRY_1], total: 1 }),
|
||||
),
|
||||
);
|
||||
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1], total: 1 })));
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
await screen.findByText('trip.create');
|
||||
expect(screen.getByText('Time')).toBeInTheDocument();
|
||||
@@ -89,11 +81,7 @@ describe('AuditLogPanel', () => {
|
||||
{ ...ENTRY_1, id: 12, username: null, user_email: null, user_id: 7, action: 'a.id' },
|
||||
{ ...ENTRY_1, id: 13, username: null, user_email: null, user_id: null, action: 'a.none' },
|
||||
];
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () =>
|
||||
HttpResponse.json({ entries, total: 4 }),
|
||||
),
|
||||
);
|
||||
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries, total: 4 })));
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
await screen.findByText('a.username');
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
@@ -121,9 +109,7 @@ describe('AuditLogPanel', () => {
|
||||
details: {},
|
||||
};
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () =>
|
||||
HttpResponse.json({ entries: [entry, entryEmptyDetails], total: 2 }),
|
||||
),
|
||||
http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [entry, entryEmptyDetails], total: 2 }))
|
||||
);
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
await screen.findByText('a.nulls');
|
||||
@@ -133,11 +119,7 @@ describe('AuditLogPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-AUDIT-006: showing count text reflects count and total', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () =>
|
||||
HttpResponse.json({ entries: [ENTRY_1], total: 50 }),
|
||||
),
|
||||
);
|
||||
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1], total: 50 })));
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
await screen.findByText('trip.create');
|
||||
expect(screen.getByText('1 loaded · 50 total')).toBeInTheDocument();
|
||||
@@ -152,7 +134,7 @@ describe('AuditLogPanel', () => {
|
||||
return HttpResponse.json({ entries: [ENTRY_1], total: 2 });
|
||||
}
|
||||
return HttpResponse.json({ entries: [ENTRY_2], total: 2 });
|
||||
}),
|
||||
})
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
@@ -166,11 +148,7 @@ describe('AuditLogPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-AUDIT-008: "Load more" hidden when all entries loaded', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () =>
|
||||
HttpResponse.json({ entries: [ENTRY_1, ENTRY_2], total: 2 }),
|
||||
),
|
||||
);
|
||||
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1, ENTRY_2], total: 2 })));
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
await screen.findByText('trip.create');
|
||||
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
|
||||
@@ -191,7 +169,7 @@ describe('AuditLogPanel', () => {
|
||||
return HttpResponse.json({ entries: [PAGE2_ENTRY], total: 2 });
|
||||
}
|
||||
return HttpResponse.json({ entries: [REFRESH_ENTRY], total: 1 });
|
||||
}),
|
||||
})
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
@@ -214,7 +192,7 @@ describe('AuditLogPanel', () => {
|
||||
http.get('/api/admin/audit-log', async () => {
|
||||
await new Promise(() => {}); // never resolves
|
||||
return HttpResponse.json({ entries: [], total: 0 });
|
||||
}),
|
||||
})
|
||||
);
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
const refreshBtn = screen.getByText('Refresh');
|
||||
|
||||
@@ -1,35 +1,123 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { RefreshCw, ClipboardList, Loader2 } from 'lucide-react'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { SETTINGS_BUTTON, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit'
|
||||
import { useAuditLog } from './useAuditLog'
|
||||
import { ClipboardList, Loader2, RefreshCw } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { adminApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { SETTINGS_BUTTON, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
|
||||
interface AuditLogPanelProps {
|
||||
serverTimezone?: string
|
||||
interface AuditEntry {
|
||||
id: number;
|
||||
created_at: string;
|
||||
user_id: number | null;
|
||||
username: string | null;
|
||||
user_email: string | null;
|
||||
action: string;
|
||||
resource: string | null;
|
||||
details: Record<string, unknown> | null;
|
||||
ip: string | null;
|
||||
}
|
||||
|
||||
const TH = 'whitespace-nowrap px-3.5 py-2.5 text-start font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
||||
const TD = 'px-3.5 py-2.5 align-top'
|
||||
const CODE = 'font-geist tabular-nums'
|
||||
interface AuditLogPanelProps {
|
||||
serverTimezone?: string;
|
||||
}
|
||||
|
||||
const TH =
|
||||
'whitespace-nowrap px-3.5 py-2.5 text-left font-geist font-bold uppercase tracking-[.08em] text-content-faint';
|
||||
const TD = 'px-3.5 py-2.5 align-top';
|
||||
const CODE = 'font-geist tabular-nums';
|
||||
|
||||
export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const { entries, total, loading, loadFirstPage, loadMore, fmtTime, fmtDetails, userLabel } = useAuditLog(serverTimezone)
|
||||
const { t, locale } = useTranslation();
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const limit = 100;
|
||||
|
||||
let body: React.ReactNode
|
||||
const loadFirstPage = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = (await adminApi.auditLog({ limit, offset: 0 })) as {
|
||||
entries: AuditEntry[];
|
||||
total: number;
|
||||
};
|
||||
setEntries(data.entries || []);
|
||||
setTotal(data.total ?? 0);
|
||||
setOffset(0);
|
||||
} catch {
|
||||
setEntries([]);
|
||||
setTotal(0);
|
||||
setOffset(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
const nextOffset = offset + limit;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = (await adminApi.auditLog({ limit, offset: nextOffset })) as {
|
||||
entries: AuditEntry[];
|
||||
total: number;
|
||||
};
|
||||
setEntries((prev) => [...prev, ...(data.entries || [])]);
|
||||
setTotal(data.total ?? 0);
|
||||
setOffset(nextOffset);
|
||||
} catch {
|
||||
/* keep existing */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [offset]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFirstPage();
|
||||
}, [loadFirstPage]);
|
||||
|
||||
const fmtTime = (iso: string) => {
|
||||
try {
|
||||
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
timeZone: serverTimezone || undefined,
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
};
|
||||
|
||||
const fmtDetails = (d: Record<string, unknown> | null) => {
|
||||
if (!d || Object.keys(d).length === 0) return '—';
|
||||
try {
|
||||
return JSON.stringify(d);
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
};
|
||||
|
||||
const userLabel = (e: AuditEntry) => {
|
||||
if (e.username) return e.username;
|
||||
if (e.user_email) return e.user_email;
|
||||
if (e.user_id != null) return `#${e.user_id}`;
|
||||
return '—';
|
||||
};
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (loading && entries.length === 0) {
|
||||
body = (
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-content-faint" style={fs(12.5, 'body')}>
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
<span>{t('common.loading')}</span>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
} else if (entries.length === 0) {
|
||||
body = <SettingsHint className="py-6 text-center">{t('admin.audit.empty')}</SettingsHint>
|
||||
body = <SettingsHint className="py-6 text-center">{t('admin.audit.empty')}</SettingsHint>;
|
||||
} else {
|
||||
body = (
|
||||
<div className={`overflow-x-auto rounded-[12px] border border-edge-faint bg-surface-card transition-opacity ${loading ? 'opacity-60' : ''}`}>
|
||||
<div
|
||||
className={`overflow-x-auto rounded-[12px] border border-edge-faint bg-surface-card transition-opacity ${loading ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<table className="w-full min-w-[720px] border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-edge-faint bg-surface-secondary" style={fs(9.5)}>
|
||||
@@ -47,10 +135,16 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
|
||||
<td className={`${TD} ${CODE} whitespace-nowrap text-content-muted`}>{fmtTime(e.created_at)}</td>
|
||||
<td className={`${TD} font-medium text-content`}>
|
||||
{/* A table cell ignores max-width under auto layout, so the box inside truncates. */}
|
||||
<span className="block max-w-[180px] truncate" title={userLabel(e)}>{userLabel(e)}</span>
|
||||
<span className="block max-w-[180px] truncate" title={userLabel(e)}>
|
||||
{userLabel(e)}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`${TD} whitespace-nowrap`}>
|
||||
<span className={`${CODE} inline-block rounded-[6px] bg-surface-tertiary px-1.5 py-[1px] text-content`}>{e.action}</span>
|
||||
<span
|
||||
className={`${CODE} inline-block rounded-[6px] bg-surface-tertiary px-1.5 py-[1px] text-content`}
|
||||
>
|
||||
{e.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`${TD} ${CODE} max-w-[160px] break-all text-content-muted`}>{e.resource || '—'}</td>
|
||||
<td className={`${TD} ${CODE} whitespace-nowrap text-content-muted`}>{e.ip || '—'}</td>
|
||||
@@ -60,7 +154,7 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -98,5 +192,5 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,10 @@
|
||||
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { Segmented } from '../shared/dialogParts'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import { Check, Clock, Download, HardDrive, Loader2, Plus, RefreshCw, RotateCcw, Trash2, Upload } from 'lucide-react';
|
||||
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||
import { backupApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { getApiErrorMessage } from '../../types';
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch';
|
||||
import {
|
||||
ChoiceChips,
|
||||
SETTINGS_BUTTON,
|
||||
@@ -16,25 +14,229 @@ import {
|
||||
SettingRows,
|
||||
SettingsCard,
|
||||
StatusPill,
|
||||
} from '../Settings/settingsKit'
|
||||
import { DAYS_OF_MONTH, DAYS_OF_WEEK, HOURS, INTERVAL_OPTIONS, KEEP_OPTIONS, useBackupAdmin } from './useBackupAdmin'
|
||||
} from '../Settings/settingsKit';
|
||||
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||
import CustomSelect from '../shared/CustomSelect';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import EmptyState from '../shared/EmptyState';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { Segmented } from '../shared/dialogParts';
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ value: 'hourly', labelKey: 'backup.interval.hourly' },
|
||||
{ value: 'daily', labelKey: 'backup.interval.daily' },
|
||||
{ value: 'weekly', labelKey: 'backup.interval.weekly' },
|
||||
{ value: 'monthly', labelKey: 'backup.interval.monthly' },
|
||||
];
|
||||
|
||||
const KEEP_OPTIONS = [
|
||||
{ value: 1, labelKey: 'backup.keep.1day' },
|
||||
{ value: 3, labelKey: 'backup.keep.3days' },
|
||||
{ value: 7, labelKey: 'backup.keep.7days' },
|
||||
{ value: 14, labelKey: 'backup.keep.14days' },
|
||||
{ value: 30, labelKey: 'backup.keep.30days' },
|
||||
{ value: 0, labelKey: 'backup.keep.forever' },
|
||||
];
|
||||
|
||||
const DAYS_OF_WEEK = [
|
||||
{ value: 0, labelKey: 'backup.dow.sunday' },
|
||||
{ value: 1, labelKey: 'backup.dow.monday' },
|
||||
{ value: 2, labelKey: 'backup.dow.tuesday' },
|
||||
{ value: 3, labelKey: 'backup.dow.wednesday' },
|
||||
{ value: 4, labelKey: 'backup.dow.thursday' },
|
||||
{ value: 5, labelKey: 'backup.dow.friday' },
|
||||
{ value: 6, labelKey: 'backup.dow.saturday' },
|
||||
];
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1);
|
||||
|
||||
/** The compact buttons of a backup row: white on a hairline, a size under the card's own. */
|
||||
const ROW_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 py-1.5 font-medium text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50'
|
||||
const ROW_BUTTON =
|
||||
'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 py-1.5 font-medium text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50';
|
||||
|
||||
interface BackupItem {
|
||||
filename: string;
|
||||
created_at?: string | null;
|
||||
size?: number | null;
|
||||
}
|
||||
|
||||
interface RestoreTarget {
|
||||
type: 'file' | 'upload';
|
||||
filename: string;
|
||||
file?: File;
|
||||
}
|
||||
|
||||
export default function BackupPanel() {
|
||||
const {
|
||||
backups, isLoading, isCreating, restoringFile, isUploading, autoSettings, autoSettingsSaving, autoSettingsDirty,
|
||||
serverTimezone, restoreConfirm, setRestoreConfirm, deleteTarget, setDeleteTarget, fileInputRef, is12h,
|
||||
loadBackups, handleCreate, handleDownload, handleRestore, handleUploadRestore, executeRestore, handleDelete,
|
||||
executeDelete, handleAutoSettingsChange, handleSaveAutoSettings, formatSize, formatDate, isAuto,
|
||||
} = useBackupAdmin()
|
||||
const { t } = useTranslation()
|
||||
const [backups, setBackups] = useState<BackupItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [restoringFile, setRestoringFile] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [autoSettings, setAutoSettings] = useState({
|
||||
enabled: false,
|
||||
interval: 'daily',
|
||||
keep_days: 7,
|
||||
hour: 2,
|
||||
day_of_week: 0,
|
||||
day_of_month: 1,
|
||||
});
|
||||
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false);
|
||||
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false);
|
||||
const [serverTimezone, setServerTimezone] = useState('');
|
||||
const [restoreConfirm, setRestoreConfirm] = useState<RestoreTarget | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const toast = useToast();
|
||||
const { t, locale } = useTranslation();
|
||||
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
|
||||
|
||||
const loadBackups = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await backupApi.list();
|
||||
setBackups(data.backups || []);
|
||||
} catch {
|
||||
toast.error(t('backup.toast.loadError'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAutoSettings = async () => {
|
||||
try {
|
||||
const data = await backupApi.getAutoSettings();
|
||||
setAutoSettings(data.settings);
|
||||
if (data.timezone) setServerTimezone(data.timezone);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadBackups();
|
||||
void loadAutoSettings();
|
||||
}, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await backupApi.create();
|
||||
toast.success(t('backup.toast.created'));
|
||||
await loadBackups();
|
||||
} catch {
|
||||
toast.error(t('backup.toast.createError'));
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = (filename: string) => {
|
||||
setRestoreConfirm({ type: 'file', filename });
|
||||
};
|
||||
|
||||
const handleUploadRestore = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = '';
|
||||
setRestoreConfirm({ type: 'upload', filename: file.name, file });
|
||||
};
|
||||
|
||||
const executeRestore = async () => {
|
||||
if (!restoreConfirm) return;
|
||||
const { type, filename, file } = restoreConfirm;
|
||||
setRestoreConfirm(null);
|
||||
|
||||
if (type === 'file') {
|
||||
setRestoringFile(filename);
|
||||
try {
|
||||
await backupApi.restore(filename);
|
||||
toast.success(t('backup.toast.restored'));
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('backup.toast.restoreError')));
|
||||
setRestoringFile(null);
|
||||
}
|
||||
} else {
|
||||
setIsUploading(true);
|
||||
try {
|
||||
await backupApi.uploadRestore(file);
|
||||
toast.success(t('backup.toast.restored'));
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('backup.toast.uploadError')));
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The question is asked in the planner's confirm dialog; the delete itself runs once it is answered.
|
||||
const handleDelete = (filename: string) => {
|
||||
setDeleteTarget(filename);
|
||||
};
|
||||
|
||||
const executeDelete = async () => {
|
||||
const filename = deleteTarget;
|
||||
setDeleteTarget(null);
|
||||
if (!filename) return;
|
||||
try {
|
||||
await backupApi.delete(filename);
|
||||
toast.success(t('backup.toast.deleted'));
|
||||
setBackups((prev) => prev.filter((b) => b.filename !== filename));
|
||||
} catch {
|
||||
toast.error(t('backup.toast.deleteError'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoSettingsChange = (key: string, value: unknown) => {
|
||||
setAutoSettings((prev) => ({ ...prev, [key]: value }));
|
||||
setAutoSettingsDirty(true);
|
||||
};
|
||||
|
||||
const handleSaveAutoSettings = async () => {
|
||||
setAutoSettingsSaving(true);
|
||||
try {
|
||||
const data = await backupApi.setAutoSettings(autoSettings);
|
||||
setAutoSettings(data.settings);
|
||||
setAutoSettingsDirty(false);
|
||||
toast.success(t('backup.toast.settingsSaved'));
|
||||
} catch {
|
||||
toast.error(t('backup.toast.settingsError'));
|
||||
} finally {
|
||||
setAutoSettingsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number | null | undefined) => {
|
||||
if (!bytes) return '-';
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string | null | undefined) => {
|
||||
if (!dateStr) return '-';
|
||||
try {
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
if (serverTimezone) opts.timeZone = serverTimezone;
|
||||
return new Date(dateStr).toLocaleString(locale, opts);
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
const isAuto = (filename: string) => filename.startsWith('auto-backup-');
|
||||
|
||||
const headerActions = (
|
||||
<>
|
||||
<Tooltip label={t('backup.refresh')}>
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadBackups}
|
||||
disabled={isLoading}
|
||||
aria-label={t('backup.refresh')}
|
||||
@@ -45,49 +247,49 @@ export default function BackupPanel() {
|
||||
</Tooltip>
|
||||
|
||||
{/* Upload & Restore */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
onChange={handleUploadRestore}
|
||||
/>
|
||||
<button type="button"
|
||||
<input ref={fileInputRef} type="file" accept=".zip" className="hidden" onChange={handleUploadRestore} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
title={isUploading ? t('backup.uploading') : t('backup.upload')}
|
||||
>
|
||||
{isUploading
|
||||
? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
|
||||
: <Upload size={14} strokeWidth={2.2} />}
|
||||
{isUploading ? (
|
||||
<Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
|
||||
) : (
|
||||
<Upload size={14} strokeWidth={2.2} />
|
||||
)}
|
||||
<span>{isUploading ? t('backup.uploading') : t('backup.upload')}</span>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(12.5, 'body')}
|
||||
title={isCreating ? t('backup.creating') : t('backup.create')}
|
||||
>
|
||||
{isCreating
|
||||
? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
|
||||
: <Plus size={14} strokeWidth={2.2} />}
|
||||
{isCreating ? (
|
||||
<Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
|
||||
) : (
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
)}
|
||||
<span>{isCreating ? t('backup.creating') : t('backup.create')}</span>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
);
|
||||
|
||||
let backupList
|
||||
let backupList;
|
||||
if (isLoading && backups.length === 0) {
|
||||
backupList = (
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-content-faint" style={fs(12.5, 'body')}>
|
||||
<Loader2 size={16} strokeWidth={2} className="animate-spin" />
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
} else if (backups.length === 0) {
|
||||
backupList = (
|
||||
<EmptyState
|
||||
@@ -103,53 +305,63 @@ export default function BackupPanel() {
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
);
|
||||
} else {
|
||||
backupList = (
|
||||
<SettingRows>
|
||||
{backups.map(backup => {
|
||||
const auto = isAuto(backup.filename)
|
||||
const restoring = restoringFile === backup.filename
|
||||
{backups.map((backup) => {
|
||||
const auto = isAuto(backup.filename);
|
||||
const restoring = restoringFile === backup.filename;
|
||||
return (
|
||||
<div key={backup.filename} className="flex flex-wrap items-center gap-x-3 gap-y-2 px-3.5 py-3">
|
||||
<span className="grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-muted">
|
||||
{auto
|
||||
? <RefreshCw size={15} strokeWidth={2} />
|
||||
: <HardDrive size={15} strokeWidth={2} />}
|
||||
{auto ? <RefreshCw size={15} strokeWidth={2} /> : <HardDrive size={15} strokeWidth={2} />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 basis-48">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="m-0 truncate font-geist font-semibold text-content" style={fs(13, 'body')}>{backup.filename}</p>
|
||||
<p className="m-0 truncate font-geist font-semibold text-content" style={fs(13, 'body')}>
|
||||
{backup.filename}
|
||||
</p>
|
||||
{auto && <StatusPill>Auto</StatusPill>}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 font-geist tabular-nums text-content-faint" style={fs(11.5)}>
|
||||
<div
|
||||
className="mt-0.5 flex items-center gap-1.5 font-geist tabular-nums text-content-faint"
|
||||
style={fs(11.5)}
|
||||
>
|
||||
<span>{formatDate(backup.created_at)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{formatSize(backup.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-none items-center gap-1.5">
|
||||
<button type="button"
|
||||
onClick={() => handleDownload(backup.filename)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))
|
||||
}
|
||||
className={ROW_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<Download size={13} strokeWidth={2.2} />
|
||||
{t('backup.download')}
|
||||
</button>
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRestore(backup.filename)}
|
||||
disabled={restoring}
|
||||
className={ROW_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{restoring
|
||||
? <Loader2 size={13} strokeWidth={2.2} className="animate-spin text-warning" />
|
||||
: <RotateCcw size={13} strokeWidth={2.2} className="text-warning" />}
|
||||
{restoring ? (
|
||||
<Loader2 size={13} strokeWidth={2.2} className="animate-spin text-warning" />
|
||||
) : (
|
||||
<RotateCcw size={13} strokeWidth={2.2} className="text-warning" />
|
||||
)}
|
||||
{t('backup.restore')}
|
||||
</button>
|
||||
<Tooltip label={t('common.delete')}>
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(backup.filename)}
|
||||
aria-label={t('common.delete')}
|
||||
className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}
|
||||
@@ -159,34 +371,28 @@ export default function BackupPanel() {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</SettingRows>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const hourOptions = HOURS.map(h => {
|
||||
let label: string
|
||||
const hourOptions = HOURS.map((h) => {
|
||||
let label: string;
|
||||
if (is12h) {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
label = `${h12}:00 ${period}`
|
||||
const period = h >= 12 ? 'PM' : 'AM';
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||
label = `${h12}:00 ${period}`;
|
||||
} else {
|
||||
label = `${String(h).padStart(2, '0')}:00`
|
||||
label = `${String(h).padStart(2, '0')}:00`;
|
||||
}
|
||||
return { value: String(h), label }
|
||||
})
|
||||
return { value: String(h), label };
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
|
||||
{/* Manual Backups */}
|
||||
<SettingsCard
|
||||
icon={HardDrive}
|
||||
title={t('backup.title')}
|
||||
hint={t('backup.subtitle')}
|
||||
action={headerActions}
|
||||
>
|
||||
<SettingsCard icon={HardDrive} title={t('backup.title')} hint={t('backup.subtitle')} action={headerActions}>
|
||||
{backupList}
|
||||
</SettingsCard>
|
||||
|
||||
@@ -196,8 +402,12 @@ export default function BackupPanel() {
|
||||
{/* The whole row is the switch's label, so a click on the text flips it too. */}
|
||||
<label className="flex cursor-pointer items-center gap-4 px-3.5 py-3">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium text-content" style={fs(13, 'body')}>{t('backup.auto.enable')}</span>
|
||||
<span className="mt-0.5 block leading-snug text-content-faint" style={fs(11.5)}>{t('backup.auto.enableHint')}</span>
|
||||
<span className="block font-medium text-content" style={fs(13, 'body')}>
|
||||
{t('backup.auto.enable')}
|
||||
</span>
|
||||
<span className="mt-0.5 block leading-snug text-content-faint" style={fs(11.5)}>
|
||||
{t('backup.auto.enableHint')}
|
||||
</span>
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
on={!!autoSettings.enabled}
|
||||
@@ -214,8 +424,8 @@ export default function BackupPanel() {
|
||||
<Segmented<string>
|
||||
label={t('backup.auto.interval')}
|
||||
value={autoSettings.interval}
|
||||
onChange={v => handleAutoSettingsChange('interval', v)}
|
||||
options={INTERVAL_OPTIONS.map(opt => ({ value: opt.value, label: t(opt.labelKey) }))}
|
||||
onChange={(v) => handleAutoSettingsChange('interval', v)}
|
||||
options={INTERVAL_OPTIONS.map((opt) => ({ value: opt.value, label: t(opt.labelKey) }))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -224,12 +434,17 @@ export default function BackupPanel() {
|
||||
{autoSettings.interval !== 'hourly' && (
|
||||
<SettingRow
|
||||
label={t('backup.auto.hour')}
|
||||
hint={<>{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}</>}
|
||||
hint={
|
||||
<>
|
||||
{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}
|
||||
{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}
|
||||
</>
|
||||
}
|
||||
control={
|
||||
<div className="w-40">
|
||||
<CustomSelect
|
||||
value={String(autoSettings.hour)}
|
||||
onChange={v => handleAutoSettingsChange('hour', Number.parseInt(String(v), 10))}
|
||||
onChange={(v) => handleAutoSettingsChange('hour', Number.parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={hourOptions}
|
||||
/>
|
||||
@@ -247,8 +462,8 @@ export default function BackupPanel() {
|
||||
<ChoiceChips<string>
|
||||
label={t('backup.auto.dayOfWeek')}
|
||||
value={String(autoSettings.day_of_week)}
|
||||
onChange={v => handleAutoSettingsChange('day_of_week', Number(v))}
|
||||
options={DAYS_OF_WEEK.map(opt => ({ value: String(opt.value), label: t(opt.labelKey) }))}
|
||||
onChange={(v) => handleAutoSettingsChange('day_of_week', Number(v))}
|
||||
options={DAYS_OF_WEEK.map((opt) => ({ value: String(opt.value), label: t(opt.labelKey) }))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -263,9 +478,9 @@ export default function BackupPanel() {
|
||||
<div className="w-28">
|
||||
<CustomSelect
|
||||
value={String(autoSettings.day_of_month)}
|
||||
onChange={v => handleAutoSettingsChange('day_of_month', Number.parseInt(String(v), 10))}
|
||||
onChange={(v) => handleAutoSettingsChange('day_of_month', Number.parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
|
||||
options={DAYS_OF_MONTH.map((d) => ({ value: String(d), label: String(d) }))}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
@@ -280,8 +495,8 @@ export default function BackupPanel() {
|
||||
<ChoiceChips<string>
|
||||
label={t('backup.auto.keepLabel')}
|
||||
value={String(autoSettings.keep_days)}
|
||||
onChange={v => handleAutoSettingsChange('keep_days', Number(v))}
|
||||
options={KEEP_OPTIONS.map(opt => ({ value: String(opt.value), label: t(opt.labelKey) }))}
|
||||
onChange={(v) => handleAutoSettingsChange('keep_days', Number(v))}
|
||||
options={KEEP_OPTIONS.map((opt) => ({ value: String(opt.value), label: t(opt.labelKey) }))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -291,15 +506,18 @@ export default function BackupPanel() {
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end">
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveAutoSettings}
|
||||
disabled={autoSettingsSaving || !autoSettingsDirty}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{autoSettingsSaving
|
||||
? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
|
||||
: <Check size={14} strokeWidth={2.2} />}
|
||||
{autoSettingsSaving ? (
|
||||
<Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
|
||||
) : (
|
||||
<Check size={14} strokeWidth={2.2} />
|
||||
)}
|
||||
{autoSettingsSaving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -319,9 +537,14 @@ export default function BackupPanel() {
|
||||
<>
|
||||
<div className="flex min-w-0 items-center gap-2.5 rounded-[12px] border border-edge-faint bg-surface-secondary px-3 py-2.5">
|
||||
<HardDrive size={15} strokeWidth={2} className="flex-none text-content-muted" />
|
||||
<span className="min-w-0 truncate font-geist font-semibold text-content" style={fs(12.5, 'body')}>{restoreConfirm.filename}</span>
|
||||
<span className="min-w-0 truncate font-geist font-semibold text-content" style={fs(12.5, 'body')}>
|
||||
{restoreConfirm.filename}
|
||||
</span>
|
||||
</div>
|
||||
<p className="m-0 rounded-[12px] bg-warning-soft px-3 py-2.5 leading-normal text-warning" style={fs(12, 'body')}>
|
||||
<p
|
||||
className="m-0 rounded-[12px] bg-warning-soft px-3 py-2.5 leading-normal text-warning"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{t('backup.restoreTip')}
|
||||
</p>
|
||||
</>
|
||||
@@ -339,5 +562,5 @@ export default function BackupPanel() {
|
||||
danger
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
// FE-COMP-CAT-001 to FE-COMP-CAT-020
|
||||
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { buildCategory, buildUser } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { fireEvent, render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildCategory } from '../../../tests/helpers/factories';
|
||||
import CategoryManager from './CategoryManager';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import CategoryManager from './CategoryManager';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
server.use(
|
||||
http.get('/api/categories', () =>
|
||||
HttpResponse.json({ categories: [] })
|
||||
),
|
||||
);
|
||||
server.use(http.get('/api/categories', () => HttpResponse.json({ categories: [] })));
|
||||
seedStore(useAuthStore, { user: buildUser({ role: 'admin' }), isAuthenticated: true });
|
||||
});
|
||||
|
||||
@@ -59,10 +55,7 @@ describe('CategoryManager', () => {
|
||||
server.use(
|
||||
http.get('/api/categories', () =>
|
||||
HttpResponse.json({
|
||||
categories: [
|
||||
buildCategory({ name: 'Museum' }),
|
||||
buildCategory({ name: 'Restaurant' }),
|
||||
],
|
||||
categories: [buildCategory({ name: 'Museum' }), buildCategory({ name: 'Restaurant' })],
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -77,13 +70,18 @@ describe('CategoryManager', () => {
|
||||
server.use(
|
||||
http.post('/api/categories', async ({ request }) => {
|
||||
postCalled = true;
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({
|
||||
category: buildCategory({ name: String(body.name) }),
|
||||
});
|
||||
})
|
||||
);
|
||||
render(<><ToastContainer /><CategoryManager /></>);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<CategoryManager />
|
||||
</>
|
||||
);
|
||||
await screen.findByText('New Category');
|
||||
await user.click(screen.getByText('New Category'));
|
||||
const nameInput = screen.getByPlaceholderText('Category name');
|
||||
@@ -95,9 +93,7 @@ describe('CategoryManager', () => {
|
||||
it('FE-COMP-CAT-008: edit button shows form for existing category', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/categories', () =>
|
||||
HttpResponse.json({ categories: [buildCategory({ id: 5, name: 'Hotels' })] })
|
||||
)
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 5, name: 'Hotels' })] }))
|
||||
);
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Hotels');
|
||||
@@ -105,7 +101,7 @@ describe('CategoryManager', () => {
|
||||
const buttons = screen.getAllByRole('button');
|
||||
// Buttons: [New Category, ...action buttons for the category]
|
||||
// The edit button is the first action button in the category row (Edit2 icon)
|
||||
const actionBtns = buttons.filter(b => !b.textContent?.includes('New Category'));
|
||||
const actionBtns = buttons.filter((b) => !b.textContent?.includes('New Category'));
|
||||
await user.click(actionBtns[0]);
|
||||
// Name input pre-filled with category name
|
||||
expect(screen.getByDisplayValue('Hotels')).toBeInTheDocument();
|
||||
@@ -115,19 +111,24 @@ describe('CategoryManager', () => {
|
||||
const user = userEvent.setup();
|
||||
let deleteCalled = false;
|
||||
server.use(
|
||||
http.get('/api/categories', () =>
|
||||
HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })
|
||||
),
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
|
||||
http.delete('/api/categories/9', () => {
|
||||
deleteCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
render(<><ToastContainer /><CategoryManager /></>);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<CategoryManager />
|
||||
</>
|
||||
);
|
||||
await screen.findByText('Parks');
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
// Nothing goes before the confirm dialog is answered
|
||||
expect(await screen.findByText('Delete category? Places in this category will not be deleted.')).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText('Delete category? Places in this category will not be deleted.')
|
||||
).toBeInTheDocument();
|
||||
expect(deleteCalled).toBe(false);
|
||||
await confirmDelete(user);
|
||||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||
@@ -166,7 +167,12 @@ describe('CategoryManager', () => {
|
||||
|
||||
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 /></>);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<CategoryManager />
|
||||
</>
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Failed to load categories')).toBeInTheDocument();
|
||||
expect(screen.getByText('No categories yet')).toBeInTheDocument();
|
||||
@@ -180,14 +186,21 @@ describe('CategoryManager', () => {
|
||||
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' }) });
|
||||
}),
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({
|
||||
category: buildCategory({ id: 5, name: 'Lodging', color: '#ef4444', icon: 'BedDouble' }),
|
||||
});
|
||||
})
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<CategoryManager />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><CategoryManager /></>);
|
||||
await screen.findByText('Hotels');
|
||||
|
||||
await user.click(screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'))[0]);
|
||||
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');
|
||||
@@ -201,10 +214,13 @@ describe('CategoryManager', () => {
|
||||
|
||||
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 })),
|
||||
server.use(http.post('/api/categories', () => HttpResponse.json({ error: 'name already taken' }, { status: 409 })));
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<CategoryManager />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><CategoryManager /></>);
|
||||
await screen.findByText('New Category');
|
||||
|
||||
await user.click(screen.getByText('New Category'));
|
||||
@@ -221,7 +237,10 @@ describe('CategoryManager', () => {
|
||||
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 }); }),
|
||||
http.delete('/api/categories/9', () => {
|
||||
deleteCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Parks');
|
||||
@@ -239,9 +258,14 @@ describe('CategoryManager', () => {
|
||||
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 })),
|
||||
http.delete('/api/categories/9', () => HttpResponse.json({ error: 'category in use' }, { status: 409 }))
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<CategoryManager />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><CategoryManager /></>);
|
||||
await screen.findByText('Parks');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
@@ -290,7 +314,7 @@ describe('CategoryManager', () => {
|
||||
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' })] })),
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 3, name: 'Hotels' })] }))
|
||||
);
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Hotels');
|
||||
|
||||
@@ -1,24 +1,136 @@
|
||||
import { useId } from 'react'
|
||||
import { Plus, Pencil, Trash2, Pipette, Tags, Loader2 } from 'lucide-react'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { SettingRows, SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON } from '../Settings/settingsKit'
|
||||
import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { ICON_NAMES, PRESET_COLORS, useCategoryAdmin } from './useCategoryAdmin'
|
||||
import { Loader2, Pencil, Pipette, Plus, Tags, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { categoriesApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { getApiErrorMessage } from '../../types';
|
||||
import {
|
||||
SETTINGS_BUTTON_PRIMARY,
|
||||
SETTINGS_ICON_BUTTON,
|
||||
SettingRows,
|
||||
SettingsCard,
|
||||
SettingsHint,
|
||||
StatusPill,
|
||||
} from '../Settings/settingsKit';
|
||||
import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../shared/categoryIcons';
|
||||
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
NEUTRAL_TINT,
|
||||
fs,
|
||||
} from '../shared/DialogShell';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#6366f1',
|
||||
'#8b5cf6',
|
||||
'#ec4899',
|
||||
'#ef4444',
|
||||
'#f97316',
|
||||
'#f59e0b',
|
||||
'#10b981',
|
||||
'#06b6d4',
|
||||
'#3b82f6',
|
||||
'#84cc16',
|
||||
'#6b7280',
|
||||
'#1f2937',
|
||||
];
|
||||
|
||||
// The colour a new category is stored with: data the server keeps, not a theme colour.
|
||||
const DEFAULT_COLOR = '#6366f1'; // theme-lint-disable: a stored category colour, not styling
|
||||
|
||||
const ICON_NAMES = Object.keys(CATEGORY_ICON_MAP);
|
||||
|
||||
export default function CategoryManager() {
|
||||
const {
|
||||
categories, showForm, editingId, form, setForm, isSaving, isLoading, deleteId, setDeleteId, colorInputRef,
|
||||
handleStartEdit, handleStartCreate, handleCancel, handleSave, handleDelete, isPresetColor,
|
||||
} = useCategoryAdmin()
|
||||
const { t } = useTranslation()
|
||||
const labelId = useId()
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' });
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
// The category whose delete waits for the answer in the confirm dialog.
|
||||
const [deleteId, setDeleteId] = useState(null);
|
||||
const colorInputRef = useRef(null);
|
||||
const toast = useToast();
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
|
||||
const PreviewIcon = getCategoryIcon(form.icon)
|
||||
const formOpen = showForm || editingId !== null
|
||||
const saveDisabled = isSaving || !form.name.trim()
|
||||
useEffect(() => {
|
||||
void loadCategories();
|
||||
}, []);
|
||||
|
||||
const loadCategories = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await categoriesApi.list();
|
||||
setCategories(data.categories || []);
|
||||
} catch (err: unknown) {
|
||||
toast.error(t('categories.toast.loadError'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartEdit = (cat) => {
|
||||
setEditingId(cat.id);
|
||||
setForm({ name: cat.name, color: cat.color || DEFAULT_COLOR, icon: cat.icon || 'MapPin' });
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const handleStartCreate = () => {
|
||||
setEditingId(null);
|
||||
setForm({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' });
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
// The Save button carries disabled={… || !form.name.trim()}, so the name is set here.
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (editingId) {
|
||||
const result = await categoriesApi.update(editingId, form);
|
||||
setCategories((prev) => prev.map((c) => (c.id === editingId ? result.category : c)));
|
||||
setEditingId(null);
|
||||
toast.success(t('categories.toast.updated'));
|
||||
} else {
|
||||
const result = await categoriesApi.create(form);
|
||||
setCategories((prev) => [...prev, result.category]);
|
||||
setShowForm(false);
|
||||
toast.success(t('categories.toast.created'));
|
||||
}
|
||||
setForm({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' });
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('categories.toast.saveError')));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await categoriesApi.delete(id);
|
||||
setCategories((prev) => prev.filter((c) => c.id !== id));
|
||||
toast.success(t('categories.toast.deleted'));
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('categories.toast.deleteError')));
|
||||
}
|
||||
};
|
||||
|
||||
const isPresetColor = PRESET_COLORS.includes(form.color);
|
||||
const PreviewIcon = getCategoryIcon(form.icon);
|
||||
const formOpen = showForm || editingId !== null;
|
||||
const saveDisabled = isSaving || !form.name.trim();
|
||||
|
||||
const categoryDialog = (
|
||||
<DialogShell
|
||||
@@ -27,18 +139,24 @@ export default function CategoryManager() {
|
||||
width="detail"
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={<DialogTile><PreviewIcon size={20} strokeWidth={1.9} color={form.color} /></DialogTile>}
|
||||
tile={
|
||||
<DialogTile>
|
||||
<PreviewIcon size={20} strokeWidth={1.9} color={form.color} />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={handleCancel}
|
||||
eyebrow={editingId ? t('common.edit') : t('categories.new')}
|
||||
titleInput={{
|
||||
value: form.name,
|
||||
onChange: name => setForm(prev => ({ ...prev, name })),
|
||||
onChange: (name) => setForm((prev) => ({ ...prev, name })),
|
||||
label: t('categories.namePlaceholder'),
|
||||
placeholder: t('categories.namePlaceholder'),
|
||||
autoFocus: true,
|
||||
onKeyDown: e => { if (e.key === 'Enter' && !saveDisabled) void handleSave() },
|
||||
onKeyDown: (e) => {
|
||||
if (e.key === 'Enter' && !saveDisabled) void handleSave();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -55,9 +173,9 @@ export default function CategoryManager() {
|
||||
<DialogSection label={t('categories.icon')}>
|
||||
<div className="max-h-52 overflow-y-auto rounded-[14px] border border-edge-faint bg-surface-secondary p-2">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(38px,1fr))] gap-1">
|
||||
{ICON_NAMES.map(name => {
|
||||
const Icon = CATEGORY_ICON_MAP[name]
|
||||
const isSelected = form.icon === name
|
||||
{ICON_NAMES.map((name) => {
|
||||
const Icon = CATEGORY_ICON_MAP[name];
|
||||
const isSelected = form.icon === name;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
@@ -65,15 +183,17 @@ export default function CategoryManager() {
|
||||
title={ICON_LABELS[name] || name}
|
||||
aria-label={ICON_LABELS[name] || name}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => setForm(prev => ({ ...prev, icon: name }))}
|
||||
onClick={() => setForm((prev) => ({ ...prev, icon: name }))}
|
||||
className={`grid h-[38px] place-items-center rounded-[10px] transition-colors ${
|
||||
isSelected ? 'bg-surface-card shadow-sm ring-2 ring-content' : 'text-content-secondary hover:bg-surface-card'
|
||||
isSelected
|
||||
? 'bg-surface-card shadow-sm ring-2 ring-content'
|
||||
: 'text-content-secondary hover:bg-surface-card'
|
||||
}`}
|
||||
style={isSelected ? { background: `${form.color}18` } : undefined}
|
||||
>
|
||||
<Icon size={17} strokeWidth={1.8} color={isSelected ? form.color : 'currentColor'} />
|
||||
</button>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,12 +201,16 @@ export default function CategoryManager() {
|
||||
|
||||
<DialogSection label={t('categories.color')}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{PRESET_COLORS.map(color => (
|
||||
<button key={color} type="button" onClick={() => setForm(prev => ({ ...prev, color }))}
|
||||
{PRESET_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => ({ ...prev, color }))}
|
||||
aria-label={color}
|
||||
aria-pressed={form.color === color}
|
||||
className={`h-7 w-7 rounded-full transition-transform hover:scale-110 ${form.color === color ? 'scale-110 ring-2 ring-content ring-offset-2 ring-offset-surface-card' : ''}`}
|
||||
style={{ backgroundColor: color }} />
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Custom color button */}
|
||||
@@ -94,7 +218,7 @@ export default function CategoryManager() {
|
||||
ref={colorInputRef}
|
||||
type="color"
|
||||
value={form.color}
|
||||
onChange={e => setForm(prev => ({ ...prev, color: e.target.value }))}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, color: e.target.value }))}
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
@@ -118,15 +242,17 @@ export default function CategoryManager() {
|
||||
|
||||
<DialogSection label={t('categories.preview')}>
|
||||
<div className="flex items-center rounded-[14px] border border-edge-faint bg-surface-secondary px-3.5 py-3">
|
||||
<span className="inline-flex min-w-0 max-w-full items-center gap-1.5 truncate rounded-full px-2.5 py-1 font-semibold"
|
||||
style={{ ...fs(12.5, 'body'), backgroundColor: `${form.color}20`, color: form.color }}>
|
||||
<span
|
||||
className="inline-flex min-w-0 max-w-full items-center gap-1.5 truncate rounded-full px-2.5 py-1 font-semibold"
|
||||
style={{ ...fs(12.5, 'body'), backgroundColor: `${form.color}20`, color: form.color }}
|
||||
>
|
||||
<PreviewIcon size={14} strokeWidth={1.8} className="flex-none" />
|
||||
{form.name || t('categories.defaultName')}
|
||||
</span>
|
||||
</div>
|
||||
</DialogSection>
|
||||
</DialogShell>
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
@@ -151,35 +277,55 @@ export default function CategoryManager() {
|
||||
</div>
|
||||
) : (
|
||||
<SettingRows>
|
||||
{categories.map(cat => {
|
||||
const Icon = getCategoryIcon(cat.icon)
|
||||
{categories.map((cat) => {
|
||||
const Icon = getCategoryIcon(cat.icon);
|
||||
return (
|
||||
<div key={cat.id} data-category-row={cat.id} className={`group flex items-center gap-3 px-3.5 py-3 ${editingId === cat.id ? 'bg-surface-secondary' : ''}`}>
|
||||
<span className="grid h-9 w-9 flex-none place-items-center rounded-[10px]"
|
||||
style={{ backgroundColor: `${cat.color}20` }}>
|
||||
<div
|
||||
key={cat.id}
|
||||
data-category-row={cat.id}
|
||||
className={`group flex items-center gap-3 px-3.5 py-3 ${editingId === cat.id ? 'bg-surface-secondary' : ''}`}
|
||||
>
|
||||
<span
|
||||
className="grid h-9 w-9 flex-none place-items-center rounded-[10px]"
|
||||
style={{ backgroundColor: `${cat.color}20` }}
|
||||
>
|
||||
<Icon size={17} strokeWidth={1.8} color={cat.color} />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="min-w-0 truncate font-medium text-content" style={fs(13, 'body')}>{cat.name}</span>
|
||||
<span className="flex-none rounded-full px-2 py-[2px] font-geist font-semibold uppercase tabular-nums"
|
||||
style={{ ...fs(10.5), backgroundColor: `${cat.color}20`, color: cat.color }}>
|
||||
<span className="min-w-0 truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{cat.name}
|
||||
</span>
|
||||
<span
|
||||
className="flex-none rounded-full px-2 py-[2px] font-geist font-semibold uppercase tabular-nums"
|
||||
style={{ ...fs(10.5), backgroundColor: `${cat.color}20`, color: cat.color }}
|
||||
>
|
||||
{cat.color}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-none items-center gap-1.5">
|
||||
<Tooltip label={t('common.edit')}>
|
||||
<button type="button" onClick={() => handleStartEdit(cat)} aria-label={t('common.edit')} className={SETTINGS_ICON_BUTTON}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStartEdit(cat)}
|
||||
aria-label={t('common.edit')}
|
||||
className={SETTINGS_ICON_BUTTON}
|
||||
>
|
||||
<Pencil size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.delete')}>
|
||||
<button type="button" onClick={() => setDeleteId(cat.id)} aria-label={t('common.delete')} className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteId(cat.id)}
|
||||
aria-label={t('common.delete')}
|
||||
className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</SettingRows>
|
||||
)}
|
||||
@@ -187,11 +333,13 @@ export default function CategoryManager() {
|
||||
<ConfirmDialog
|
||||
isOpen={deleteId !== null}
|
||||
onClose={() => setDeleteId(null)}
|
||||
onConfirm={() => { if (deleteId !== null) void handleDelete(deleteId) }}
|
||||
onConfirm={() => {
|
||||
if (deleteId !== null) void handleDelete(deleteId);
|
||||
}}
|
||||
message={t('categories.confirm.delete')}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
/>
|
||||
</SettingsCard>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-029
|
||||
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
|
||||
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-028
|
||||
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 { server } from '../../../tests/helpers/msw/server';
|
||||
import { fireEvent, render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { AMAP_ROAD } from '../../constants/mapDefaults';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import DefaultUserSettingsTab from './DefaultUserSettingsTab';
|
||||
|
||||
@@ -28,20 +27,25 @@ function stubDefaults(initial: 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>;
|
||||
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 /></>);
|
||||
return render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DefaultUserSettingsTab />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The selected option is the pressed button of its segmented group. */
|
||||
@@ -54,13 +58,13 @@ function isActive(button: HTMLElement): boolean {
|
||||
* 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');
|
||||
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');
|
||||
const el = screen.getAllByText(label).find((node) => node.tagName === 'LABEL');
|
||||
return !!el && within(el).queryByRole('button') !== null;
|
||||
}
|
||||
|
||||
@@ -98,7 +102,14 @@ describe('DefaultUserSettingsTab', () => {
|
||||
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']) {
|
||||
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();
|
||||
@@ -154,11 +165,9 @@ describe('DefaultUserSettingsTab', () => {
|
||||
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' },
|
||||
]));
|
||||
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 () => {
|
||||
@@ -201,10 +210,7 @@ describe('DefaultUserSettingsTab', () => {
|
||||
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 },
|
||||
]));
|
||||
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 () => {
|
||||
@@ -221,18 +227,6 @@ describe('DefaultUserSettingsTab', () => {
|
||||
expect(screen.getByTestId('map-preview')).toHaveAttribute('data-tile', url);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-029: the Amap presets a user can pick are offered as instance defaults too', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await pickFromSelect(user, 'Select template...', '高德地图 (Amap)');
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ map_tile_url: AMAP_ROAD }]));
|
||||
expect(screen.getByTestId('map-preview')).toHaveAttribute('data-tile', AMAP_ROAD);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-011: a hand-typed tile URL is saved on blur', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
@@ -412,7 +406,7 @@ describe('DefaultUserSettingsTab', () => {
|
||||
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 })),
|
||||
http.put('/api/admin/default-user-settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))
|
||||
);
|
||||
withToast();
|
||||
await screen.findByText('Default User Settings');
|
||||
@@ -427,7 +421,7 @@ describe('DefaultUserSettingsTab', () => {
|
||||
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 })),
|
||||
http.put('/api/admin/default-user-settings', () => HttpResponse.json({ error: 'nope' }, { status: 503 }))
|
||||
);
|
||||
withToast();
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
@@ -1,20 +1,73 @@
|
||||
import React from 'react'
|
||||
import { Loader2, Map as MapIcon, Settings2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import Section from '../Settings/Section'
|
||||
import { SettingRows, SettingsHint } from '../Settings/settingsKit'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
|
||||
import type { DistanceUnit, WeekStart } from '../../types'
|
||||
import { weekStartOptions } from '../../utils/calendarWeek'
|
||||
import { withTileApiKey } from '../../utils/tileUrl'
|
||||
import { defaultStyleForProvider } from '../Map/glProviders'
|
||||
import { MAP_PRESETS, type MapProvider } from '../Settings/mapSettingsModel'
|
||||
import RoutingInstanceFields from './RoutingInstanceFields'
|
||||
import { useDefaultUserSettings, type Defaults } from './useDefaultUserSettings'
|
||||
import { Loader2, Map as MapIcon, Settings2 } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { adminApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import type { DistanceUnit, Place, WeekStart } from '../../types';
|
||||
import { weekStartOptions } from '../../utils/calendarWeek';
|
||||
import { normalizeTileUrl, withTileApiKey } from '../../utils/tileUrl';
|
||||
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants';
|
||||
import { MapView } from '../Map/MapView';
|
||||
import {
|
||||
MAPBOX_DEFAULT_STYLE,
|
||||
defaultStyleForProvider,
|
||||
getStylePresets,
|
||||
isOpenFreeMapStyle,
|
||||
normalizeStyleForProvider,
|
||||
styleSettingKey,
|
||||
type GlMapProvider,
|
||||
} from '../Map/glProviders';
|
||||
import Section from '../Settings/Section';
|
||||
import { SettingRows, SettingsHint } from '../Settings/settingsKit';
|
||||
import CustomSelect from '../shared/CustomSelect';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { EditorField, INPUT, Segmented } from '../shared/dialogParts';
|
||||
import RoutingInstanceFields, { type RoutingDefaults } from './RoutingInstanceFields';
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
|
||||
// The app default, and a vector style rather than a {z}/{x}/{y} template: no
|
||||
// key, no registration, no request limits.
|
||||
{ name: 'OpenFreeMap Positron', url: 'https://tiles.openfreemap.org/styles/positron' },
|
||||
{ name: 'OpenFreeMap Bright', url: 'https://tiles.openfreemap.org/styles/bright' },
|
||||
// CARTO watermarks keyless tiles since 26.08.2026 and issues keys by mail, so
|
||||
// these two need one; without it the map falls back to the default (#2054).
|
||||
{ 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' },
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
];
|
||||
|
||||
type Defaults = RoutingDefaults & {
|
||||
temperature_unit?: string;
|
||||
distance_unit?: DistanceUnit;
|
||||
dark_mode?: string | boolean;
|
||||
time_format?: string;
|
||||
week_start?: WeekStart;
|
||||
default_currency?: string;
|
||||
blur_booking_codes?: boolean;
|
||||
map_tile_url?: string;
|
||||
carto_api_key?: 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* One default as a row of the card's white box: its name (with the reset link
|
||||
@@ -28,81 +81,184 @@ function ChoiceRow({
|
||||
stacked = false,
|
||||
children,
|
||||
}: {
|
||||
label: React.ReactNode
|
||||
hint?: string
|
||||
stacked?: boolean
|
||||
children: React.ReactNode
|
||||
label: React.ReactNode;
|
||||
hint?: string;
|
||||
stacked?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={stacked ? 'flex flex-col gap-2.5 px-3.5 py-3' : 'flex flex-wrap items-center gap-x-4 gap-y-2 px-3.5 py-3'}>
|
||||
<label className={`block font-medium text-content ${stacked ? '' : 'min-w-0 flex-1 basis-40'}`} style={fs(13, 'body')}>
|
||||
<div
|
||||
className={
|
||||
stacked ? 'flex flex-col gap-2.5 px-3.5 py-3' : 'flex flex-wrap items-center gap-x-4 gap-y-2 px-3.5 py-3'
|
||||
}
|
||||
>
|
||||
<label
|
||||
className={`block font-medium text-content ${stacked ? '' : 'min-w-0 flex-1 basis-40'}`}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{hint && <p className="-mt-2 m-0 leading-snug text-content-faint" style={fs(11.5)}>{hint}</p>}
|
||||
{hint && (
|
||||
<p className="m-0 -mt-2 leading-snug text-content-faint" style={fs(11.5)}>
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
<div className={stacked ? 'min-w-0' : 'flex min-w-0 flex-none items-center'}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** A wide field box inside the white rows, for the map card's token and style. */
|
||||
const FIELD_ROW = 'px-3.5 py-3'
|
||||
const FIELD_ROW = 'px-3.5 py-3';
|
||||
|
||||
/** On/Off for a boolean default; neither is pressed while it is unset. */
|
||||
function onOffValue(value: boolean | undefined): string {
|
||||
if (value === true) return 'on'
|
||||
if (value === false) return 'off'
|
||||
return ''
|
||||
if (value === true) return 'on';
|
||||
if (value === false) return 'off';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** The stored colour mode as one of the three choices; a legacy boolean still picks light or dark. */
|
||||
function colorModeValue(value: string | boolean | undefined): string {
|
||||
if (value === true) return 'dark'
|
||||
if (value === false) return 'light'
|
||||
return value ?? ''
|
||||
}
|
||||
|
||||
/** A failed save names the error it threw, or falls back to the generic one. */
|
||||
function errorText(err: unknown, fallback: string): string {
|
||||
return err instanceof Error ? err.message : fallback
|
||||
if (value === true) return 'dark';
|
||||
if (value === false) return 'light';
|
||||
return value ?? '';
|
||||
}
|
||||
|
||||
export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const {
|
||||
defaults, loaded, managed, isSet, save, reset, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
|
||||
cartoKey, setCartoKey, mapboxStyle, setMapboxStyle, mapPreviewPlaces, mapProvider, glStylePresets, styleKey,
|
||||
saveMapProvider, pickTilePreset, pickStylePreset, commitStyle,
|
||||
} = useDefaultUserSettings({ errorMessage: errorText, previewCreatedAt: () => String(new Date()) })
|
||||
const { t, locale } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [defaults, setDefaults] = useState<Defaults>({});
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [mapTileUrl, setMapTileUrl] = useState('');
|
||||
const managed = useAuthStore((s) => s.managed);
|
||||
const [mapboxToken, setMapboxToken] = useState('');
|
||||
const [cartoKey, setCartoKey] = 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 || '');
|
||||
setCartoKey(data.carto_api_key || '');
|
||||
setMapboxStyle(
|
||||
provider === 'leaflet'
|
||||
? data.mapbox_style || ''
|
||||
: styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style)
|
||||
);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => setLoaded(true));
|
||||
}, []);
|
||||
|
||||
const save = async (patch: Partial<Defaults>) => {
|
||||
try {
|
||||
const updated = await adminApi.updateDefaultUserSettings(patch as Record<string, unknown>);
|
||||
setDefaults(updated);
|
||||
toast.success(t('admin.defaultSettings.saved'));
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const reset = async (key: keyof Defaults) => {
|
||||
try {
|
||||
const updated = await adminApi.updateDefaultUserSettings({ [key]: null });
|
||||
setDefaults(updated);
|
||||
if (key === 'map_tile_url') setMapTileUrl('');
|
||||
if (key === 'mapbox_access_token') setMapboxToken('');
|
||||
if (key === 'carto_api_key') setCartoKey('');
|
||||
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'));
|
||||
}
|
||||
};
|
||||
|
||||
const isSet = (key: keyof Defaults) => defaults[key] !== undefined;
|
||||
|
||||
// Inside the row's own <label>, after the name: the link only shows once the
|
||||
// default is set, and the name stays the label's text.
|
||||
const ResetButton = ({ field }: { field: keyof Defaults }) =>
|
||||
isSet(field) ? (
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reset(field)}
|
||||
className="ms-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
|
||||
className="ml-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
|
||||
style={fs(11)}
|
||||
>
|
||||
{t('admin.defaultSettings.resetToBuiltIn')}
|
||||
</button>
|
||||
) : null
|
||||
) : null;
|
||||
|
||||
const mapPreviewPlaces = useMemo(
|
||||
(): Place[] => [
|
||||
{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
name: 'Preview center',
|
||||
description: null,
|
||||
notes: null,
|
||||
lat: 48.8566,
|
||||
lng: 2.3522,
|
||||
address: null,
|
||||
category_id: null,
|
||||
price: null,
|
||||
currency: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
osm_id: null,
|
||||
route_geometry: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
duration_minutes: null,
|
||||
transport_mode: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
created_at: String(new Date()),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-2xl border border-edge-faint bg-surface-secondary px-4 py-5 text-content-faint" style={fs(12.5, 'body')}>
|
||||
<div
|
||||
className="flex items-center gap-2.5 rounded-2xl border border-edge-faint bg-surface-secondary px-4 py-5 text-content-faint"
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const darkMode = defaults.dark_mode
|
||||
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;
|
||||
}
|
||||
void save(patch);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHint className="mb-4">
|
||||
{t('admin.defaultSettings.description')}
|
||||
</SettingsHint>
|
||||
<SettingsHint className="mb-4">{t('admin.defaultSettings.description')}</SettingsHint>
|
||||
|
||||
{/* Two columns from xl up, the same idea as the Settings tab: as one flat list
|
||||
the map fields sat a screen below the units while the right half of the page
|
||||
@@ -115,252 +271,364 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
collapses. Two cards can sit straight in the grid; a third would need the
|
||||
explicit per-column stacks the Settings tab uses, or it leaves a hole. */}
|
||||
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)] xl:items-start">
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
<SettingRows>
|
||||
{/* Color Mode */}
|
||||
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.colorMode')}
|
||||
value={colorModeValue(darkMode)}
|
||||
onChange={value => save({ dark_mode: value })}
|
||||
options={[
|
||||
{ value: 'light', label: t('settings.light') },
|
||||
{ value: 'dark', label: t('settings.dark') },
|
||||
{ value: 'auto', label: t('settings.auto') },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
<SettingRows>
|
||||
{/* Color Mode */}
|
||||
<ChoiceRow
|
||||
label={
|
||||
<>
|
||||
{t('settings.colorMode')} <ResetButton field="dark_mode" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Segmented<string>
|
||||
label={t('settings.colorMode')}
|
||||
value={colorModeValue(darkMode)}
|
||||
onChange={(value) => save({ dark_mode: value })}
|
||||
options={[
|
||||
{ value: 'light', label: t('settings.light') },
|
||||
{ value: 'dark', label: t('settings.dark') },
|
||||
{ value: 'auto', label: t('settings.auto') },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Temperature */}
|
||||
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.temperature')}
|
||||
value={defaults.temperature_unit ?? ''}
|
||||
onChange={value => save({ temperature_unit: value })}
|
||||
options={[
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
{/* Temperature */}
|
||||
<ChoiceRow
|
||||
label={
|
||||
<>
|
||||
{t('settings.temperature')} <ResetButton field="temperature_unit" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Segmented<string>
|
||||
label={t('settings.temperature')}
|
||||
value={defaults.temperature_unit ?? ''}
|
||||
onChange={(value) => save({ temperature_unit: value })}
|
||||
options={[
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Distance */}
|
||||
<ChoiceRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
|
||||
<Segmented<DistanceUnit | ''>
|
||||
label={t('settings.distance')}
|
||||
value={defaults.distance_unit ?? ''}
|
||||
onChange={value => { if (value) void save({ distance_unit: value }) }}
|
||||
options={[
|
||||
{ value: 'metric', label: 'km Metric' },
|
||||
{ value: 'imperial', label: 'mi Imperial' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
{/* Distance */}
|
||||
<ChoiceRow
|
||||
label={
|
||||
<>
|
||||
{t('settings.distance')} <ResetButton field="distance_unit" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Segmented<DistanceUnit | ''>
|
||||
label={t('settings.distance')}
|
||||
value={defaults.distance_unit ?? ''}
|
||||
onChange={(value) => {
|
||||
if (value) void save({ distance_unit: value });
|
||||
}}
|
||||
options={[
|
||||
{ value: 'metric', label: 'km Metric' },
|
||||
{ value: 'imperial', label: 'mi Imperial' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Time Format */}
|
||||
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.timeFormat')}
|
||||
value={defaults.time_format ?? ''}
|
||||
onChange={value => save({ time_format: value })}
|
||||
options={[
|
||||
{ value: '24h', label: '24h (14:30)' },
|
||||
{ value: '12h', label: '12h (2:30 PM)' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
{/* Time Format */}
|
||||
<ChoiceRow
|
||||
label={
|
||||
<>
|
||||
{t('settings.timeFormat')} <ResetButton field="time_format" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Segmented<string>
|
||||
label={t('settings.timeFormat')}
|
||||
value={defaults.time_format ?? ''}
|
||||
onChange={(value) => save({ time_format: value })}
|
||||
options={[
|
||||
{ value: '24h', label: '24h (14:30)' },
|
||||
{ value: '12h', label: '12h (2:30 PM)' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Week start: the first column of every date picker (#2029) */}
|
||||
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton field="week_start" /></>}>
|
||||
<Segmented<WeekStart | ''>
|
||||
label={t('settings.weekStart')}
|
||||
value={defaults.week_start ?? ''}
|
||||
onChange={value => { if (value) void save({ week_start: value }) }}
|
||||
options={weekStartOptions(locale)}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
{/* Week start: the first column of every date picker (#2029) */}
|
||||
<ChoiceRow
|
||||
label={
|
||||
<>
|
||||
{t('settings.weekStart')} <ResetButton field="week_start" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Segmented<WeekStart | ''>
|
||||
label={t('settings.weekStart')}
|
||||
value={defaults.week_start ?? ''}
|
||||
onChange={(value) => {
|
||||
if (value) void save({ week_start: value });
|
||||
}}
|
||||
options={weekStartOptions(locale)}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Default Currency */}
|
||||
<ChoiceRow label={<>{t('settings.currency')} <ResetButton field="default_currency" /></>}>
|
||||
<div className="w-[220px] max-w-full">
|
||||
<CustomSelect
|
||||
value={defaults.default_currency || ''}
|
||||
onChange={(value: string) => { if (value) void 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 }))}
|
||||
/>
|
||||
</div>
|
||||
</ChoiceRow>
|
||||
{/* Default Currency */}
|
||||
<ChoiceRow
|
||||
label={
|
||||
<>
|
||||
{t('settings.currency')} <ResetButton field="default_currency" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="w-[220px] max-w-full">
|
||||
<CustomSelect
|
||||
value={defaults.default_currency || ''}
|
||||
onChange={(value: string) => {
|
||||
if (value) void 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,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.blurBookingCodes')}
|
||||
value={onOffValue(defaults.blur_booking_codes)}
|
||||
onChange={value => save({ blur_booking_codes: value === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') || 'On' },
|
||||
{ value: 'off', label: t('settings.off') || 'Off' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
</SettingRows>
|
||||
<SettingsHint className="-mt-2">{t('settings.currencyHint')}</SettingsHint>
|
||||
</Section>
|
||||
{/* Blur Booking Codes */}
|
||||
<ChoiceRow
|
||||
label={
|
||||
<>
|
||||
{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Segmented<string>
|
||||
label={t('settings.blurBookingCodes')}
|
||||
value={onOffValue(defaults.blur_booking_codes)}
|
||||
onChange={(value) => save({ blur_booking_codes: value === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') || 'On' },
|
||||
{ value: 'off', label: t('settings.off') || 'Off' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
</SettingRows>
|
||||
<SettingsHint className="-mt-2">{t('settings.currencyHint')}</SettingsHint>
|
||||
</Section>
|
||||
|
||||
<Section title={t('settings.map')} icon={MapIcon}>
|
||||
{/* Map Tile URL */}
|
||||
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton field="map_tile_url" /></>} hint={t('settings.mapDefaultHint')}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={pickTilePreset}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
className={`${INPUT} font-geist`}
|
||||
/>
|
||||
</div>
|
||||
</EditorField>
|
||||
{/* The key comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{!managed && (
|
||||
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton field="carto_api_key" /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
|
||||
<input
|
||||
type="text"
|
||||
value={cartoKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCartoKey(e.target.value)}
|
||||
onBlur={() => save({ carto_api_key: cartoKey })}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className={`${INPUT} font-geist`}
|
||||
/>
|
||||
</EditorField>
|
||||
)}
|
||||
{!managed && <RoutingInstanceFields defaults={defaults} onSave={save} onReset={reset} />}
|
||||
<div className="relative h-[200px] w-full overflow-hidden rounded-[12px] border border-edge-faint">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
places: mapPreviewPlaces,
|
||||
dayPlaces: [],
|
||||
route: null,
|
||||
routeSegments: null,
|
||||
selectedPlaceId: null,
|
||||
onMarkerClick: null,
|
||||
onMapClick: null,
|
||||
onMapContextMenu: null,
|
||||
center: [48.8566, 2.3522],
|
||||
zoom: 10,
|
||||
// Same as the user-facing map tab: the field holds what is being
|
||||
// edited, so the key goes back on before the preview resolves it.
|
||||
tileUrl: withTileApiKey(mapTileUrl, cartoKey),
|
||||
fitKey: null,
|
||||
dayOrderMap: [],
|
||||
leftWidth: 0,
|
||||
rightWidth: 0,
|
||||
hasInspector: false,
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Map provider / instance-wide Mapbox ───────────────────────── */}
|
||||
<SettingRows>
|
||||
<ChoiceRow
|
||||
stacked
|
||||
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
|
||||
hint={t('admin.defaultSettings.mapProviderHint')}
|
||||
>
|
||||
<Segmented<MapProvider>
|
||||
fill
|
||||
label={t('admin.defaultSettings.mapProvider')}
|
||||
value={mapProvider}
|
||||
onChange={saveMapProvider}
|
||||
options={[
|
||||
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
|
||||
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
|
||||
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* The token comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{mapProvider === 'mapbox-gl' && !managed && (
|
||||
<div className={FIELD_ROW}>
|
||||
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton field="mapbox_access_token" /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
|
||||
<Section title={t('settings.map')} icon={MapIcon}>
|
||||
{/* Map Tile URL */}
|
||||
<EditorField
|
||||
label={
|
||||
<>
|
||||
{t('settings.mapTemplate')}
|
||||
<ResetButton field="map_tile_url" />
|
||||
</>
|
||||
}
|
||||
hint={t('settings.mapDefaultHint')}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => {
|
||||
if (value) {
|
||||
setMapTileUrl(value);
|
||||
void save({ map_tile_url: value });
|
||||
}
|
||||
}}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map((p) => ({ value: p.url, label: p.name }))}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxToken}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxToken(e.target.value)}
|
||||
onBlur={() => save({ mapbox_access_token: mapboxToken })}
|
||||
placeholder="pk.eyJ…"
|
||||
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"
|
||||
className={`${INPUT} font-geist`}
|
||||
/>
|
||||
</div>
|
||||
</EditorField>
|
||||
{/* The key comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{!managed && (
|
||||
<EditorField
|
||||
label={
|
||||
<>
|
||||
{t('admin.defaultSettings.cartoKey')}
|
||||
<ResetButton field="carto_api_key" />
|
||||
</>
|
||||
}
|
||||
hint={t('admin.defaultSettings.cartoKeyHint')}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={cartoKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCartoKey(e.target.value)}
|
||||
onBlur={() => save({ carto_api_key: cartoKey })}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className={`${INPUT} font-geist`}
|
||||
/>
|
||||
</EditorField>
|
||||
)}
|
||||
{!managed && <RoutingInstanceFields defaults={defaults} onSave={save} onReset={reset} />}
|
||||
<div className="relative h-[200px] w-full overflow-hidden rounded-[12px] border border-edge-faint">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
places: mapPreviewPlaces,
|
||||
dayPlaces: [],
|
||||
route: null,
|
||||
routeSegments: null,
|
||||
selectedPlaceId: null,
|
||||
onMarkerClick: null,
|
||||
onMapClick: null,
|
||||
onMapContextMenu: null,
|
||||
center: [48.8566, 2.3522],
|
||||
zoom: 10,
|
||||
// Same as the user-facing map tab: the field holds what is being
|
||||
// edited, so the key goes back on before the preview resolves it.
|
||||
tileUrl: withTileApiKey(mapTileUrl, cartoKey),
|
||||
fitKey: null,
|
||||
dayOrderMap: [],
|
||||
leftWidth: 0,
|
||||
rightWidth: 0,
|
||||
hasInspector: false,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mapProvider !== 'leaflet' && (
|
||||
<div className={FIELD_ROW}>
|
||||
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton field={styleKey} /></>}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CustomSelect
|
||||
value={mapboxStyle}
|
||||
onChange={pickStylePreset}
|
||||
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
|
||||
options={glStylePresets.map(p => ({ value: p.url, label: p.name }))}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxStyle}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
|
||||
onBlur={commitStyle}
|
||||
placeholder={defaultStyleForProvider(mapProvider)}
|
||||
className={`${INPUT} font-geist`}
|
||||
/>
|
||||
{/* ── Map provider / instance-wide Mapbox ───────────────────────── */}
|
||||
<SettingRows>
|
||||
<ChoiceRow
|
||||
stacked
|
||||
label={
|
||||
<>
|
||||
{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" />
|
||||
</>
|
||||
}
|
||||
hint={t('admin.defaultSettings.mapProviderHint')}
|
||||
>
|
||||
<Segmented<MapProvider>
|
||||
fill
|
||||
label={t('admin.defaultSettings.mapProvider')}
|
||||
value={mapProvider}
|
||||
onChange={saveMapProvider}
|
||||
options={[
|
||||
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
|
||||
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
|
||||
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* The token comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{mapProvider === 'mapbox-gl' && !managed && (
|
||||
<div className={FIELD_ROW}>
|
||||
<EditorField
|
||||
label={
|
||||
<>
|
||||
{t('admin.defaultSettings.mapboxToken')}
|
||||
<ResetButton field="mapbox_access_token" />
|
||||
</>
|
||||
}
|
||||
hint={t('admin.defaultSettings.mapboxTokenHint')}
|
||||
>
|
||||
<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={`${INPUT} font-geist`}
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
</EditorField>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{mapProvider === 'mapbox-gl' && (
|
||||
<>
|
||||
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
|
||||
<Segmented<string>
|
||||
label={t('admin.defaultSettings.mapbox3d')}
|
||||
value={onOffValue(defaults.mapbox_3d_enabled ?? true)}
|
||||
onChange={value => save({ mapbox_3d_enabled: value === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') || 'On' },
|
||||
{ value: 'off', label: t('settings.off') || 'Off' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
{mapProvider !== 'leaflet' && (
|
||||
<div className={FIELD_ROW}>
|
||||
<EditorField
|
||||
label={
|
||||
<>
|
||||
{t('admin.defaultSettings.mapboxStyle')}
|
||||
<ResetButton field={styleKey} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CustomSelect
|
||||
value={mapboxStyle}
|
||||
onChange={(value: string) => {
|
||||
if (value) {
|
||||
setMapboxStyle(value);
|
||||
void save({ [styleKey]: value });
|
||||
}
|
||||
}}
|
||||
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
|
||||
options={glStylePresets.map((p) => ({ value: p.url, label: p.name }))}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxStyle}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
|
||||
onBlur={() => {
|
||||
const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle);
|
||||
setMapboxStyle(nextStyle);
|
||||
void save({ [styleKey]: nextStyle });
|
||||
}}
|
||||
placeholder={defaultStyleForProvider(mapProvider)}
|
||||
className={`${INPUT} font-geist`}
|
||||
/>
|
||||
</div>
|
||||
</EditorField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
|
||||
<Segmented<string>
|
||||
label={t('admin.defaultSettings.mapboxQuality')}
|
||||
value={onOffValue(defaults.mapbox_quality_mode ?? false)}
|
||||
onChange={value => save({ mapbox_quality_mode: value === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') || 'On' },
|
||||
{ value: 'off', label: t('settings.off') || 'Off' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
</>
|
||||
)}
|
||||
</SettingRows>
|
||||
</Section>
|
||||
{mapProvider === 'mapbox-gl' && (
|
||||
<>
|
||||
<ChoiceRow
|
||||
label={
|
||||
<>
|
||||
{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Segmented<string>
|
||||
label={t('admin.defaultSettings.mapbox3d')}
|
||||
value={onOffValue(defaults.mapbox_3d_enabled ?? true)}
|
||||
onChange={(value) => save({ mapbox_3d_enabled: value === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') || 'On' },
|
||||
{ value: 'off', label: t('settings.off') || 'Off' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
<ChoiceRow
|
||||
label={
|
||||
<>
|
||||
{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Segmented<string>
|
||||
label={t('admin.defaultSettings.mapboxQuality')}
|
||||
value={onOffValue(defaults.mapbox_quality_mode ?? false)}
|
||||
onChange={(value) => save({ mapbox_quality_mode: value === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') || 'On' },
|
||||
{ value: 'off', label: t('settings.off') || 'Off' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
</>
|
||||
)}
|
||||
</SettingRows>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-016
|
||||
import { render, screen, waitFor, 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 { buildUser } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
@@ -22,12 +22,22 @@ afterEach(() => {
|
||||
|
||||
describe('DevNotificationsPanel', () => {
|
||||
it('FE-ADMIN-DEVNOTIF-001: "DEV ONLY" badge is always visible', () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
expect(screen.getByText('DEV ONLY')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-002: four section titles render after data loads', async () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
// Wait for async data to populate conditional sections
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
await screen.findByText('User-Scoped Events');
|
||||
@@ -36,37 +46,52 @@ describe('DevNotificationsPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-003: trip selector populated from API', async () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
const [tripSelect] = screen.getAllByRole('combobox');
|
||||
const options = Array.from(tripSelect.querySelectorAll('option'));
|
||||
const labels = options.map(o => o.textContent);
|
||||
const labels = options.map((o) => o.textContent);
|
||||
expect(labels).toContain('Paris Adventure');
|
||||
expect(labels).toContain('Tokyo Trip');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-004: user selector populated from API', async () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
await screen.findByText('User-Scoped Events');
|
||||
const selects = screen.getAllByRole('combobox');
|
||||
// Second combobox is the user selector (first is trip selector)
|
||||
const userSelect = selects[1];
|
||||
const options = Array.from(userSelect.querySelectorAll('option'));
|
||||
const labels = options.map(o => o.textContent ?? '');
|
||||
expect(labels.some(l => l.includes('admin'))).toBe(true);
|
||||
expect(labels.some(l => l.includes('alice'))).toBe(true);
|
||||
const labels = options.map((o) => o.textContent ?? '');
|
||||
expect(labels.some((l) => l.includes('admin'))).toBe(true);
|
||||
expect(labels.some((l) => l.includes('alice'))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-005: clicking "Simple → Me" fires sendTestNotification with correct payload', async () => {
|
||||
let capturedBody: Record<string, unknown> | undefined;
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', async ({ request }) => {
|
||||
capturedBody = await request.json() as Record<string, unknown>;
|
||||
capturedBody = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
})
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
await waitFor(() => expect(capturedBody).toBeDefined());
|
||||
@@ -78,13 +103,14 @@ describe('DevNotificationsPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-006: success toast shown after fire', async () => {
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', () =>
|
||||
HttpResponse.json({ ok: true }),
|
||||
),
|
||||
);
|
||||
server.use(http.post('/api/admin/dev/test-notification', () => HttpResponse.json({ ok: true })));
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
expect(await screen.findByText('Sent: simple-me')).toBeInTheDocument();
|
||||
@@ -95,10 +121,15 @@ describe('DevNotificationsPanel', () => {
|
||||
http.post('/api/admin/dev/test-notification', async () => {
|
||||
await new Promise(() => {}); // never resolves — simulates in-flight
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
})
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
await screen.findByText('Type Testing');
|
||||
|
||||
// The request handler never resolves, so sending stays true after the click settles
|
||||
@@ -106,18 +137,23 @@ describe('DevNotificationsPanel', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach(btn => expect(btn).toBeDisabled());
|
||||
buttons.forEach((btn) => expect(btn).toBeDisabled());
|
||||
});
|
||||
});
|
||||
|
||||
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 }),
|
||||
),
|
||||
HttpResponse.json({ error: 'No channel configured' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
expect(await screen.findByText('No channel configured')).toBeInTheDocument();
|
||||
@@ -126,11 +162,16 @@ describe('DevNotificationsPanel', () => {
|
||||
it('FE-ADMIN-DEVNOTIF-008b: a failure without an error field falls back to the generic text', async () => {
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', () =>
|
||||
HttpResponse.json({ message: 'Server error' }, { status: 500 }),
|
||||
),
|
||||
HttpResponse.json({ message: 'Server error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
expect(await screen.findByText('Failed')).toBeInTheDocument();
|
||||
@@ -140,18 +181,21 @@ describe('DevNotificationsPanel', () => {
|
||||
let capturedBody: Record<string, unknown> | undefined;
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', async ({ request }) => {
|
||||
capturedBody = await request.json() as Record<string, unknown>;
|
||||
capturedBody = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
})
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
|
||||
const [tripSelect] = screen.getAllByRole('combobox');
|
||||
const tokyoOption = Array.from(tripSelect.querySelectorAll('option')).find(
|
||||
o => o.textContent === 'Tokyo Trip',
|
||||
)!;
|
||||
const tokyoOption = Array.from(tripSelect.querySelectorAll('option')).find((o) => o.textContent === 'Tokyo Trip')!;
|
||||
const tokyoId = Number(tokyoOption.value);
|
||||
|
||||
await user.selectOptions(tripSelect, 'Tokyo Trip');
|
||||
@@ -162,10 +206,13 @@ describe('DevNotificationsPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-010: Trip-Scoped section absent when no trips', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips', () => HttpResponse.json({ trips: [] })),
|
||||
server.use(http.get('/api/trips', () => HttpResponse.json({ trips: [] })));
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
// Wait for user data to confirm async effects have settled
|
||||
await screen.findByText('User-Scoped Events');
|
||||
expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument();
|
||||
@@ -175,12 +222,17 @@ describe('DevNotificationsPanel', () => {
|
||||
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>);
|
||||
bodies.push((await request.json()) as Record<string, unknown>);
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
})
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
await screen.findByText('Type Testing');
|
||||
|
||||
await user.click(screen.getByText('Boolean → Me').closest('button')!);
|
||||
@@ -204,19 +256,29 @@ describe('DevNotificationsPanel', () => {
|
||||
});
|
||||
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' } });
|
||||
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>);
|
||||
bodies.push((await request.json()) as Record<string, unknown>);
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
})
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
|
||||
const [tripSelect] = screen.getAllByRole('combobox');
|
||||
@@ -227,7 +289,7 @@ describe('DevNotificationsPanel', () => {
|
||||
await screen.findByText(`Sent: ${label}`);
|
||||
}
|
||||
|
||||
expect(bodies.map(b => b.event)).toEqual(['trip_reminder', 'photos_shared', 'collab_message', 'packing_tagged']);
|
||||
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);
|
||||
@@ -242,17 +304,22 @@ describe('DevNotificationsPanel', () => {
|
||||
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>);
|
||||
bodies.push((await request.json()) as Record<string, unknown>);
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
})
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
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'),
|
||||
const aliceOption = Array.from(userSelect.querySelectorAll('option')).find((o) =>
|
||||
(o.textContent ?? '').includes('alice')
|
||||
)!;
|
||||
await user.selectOptions(userSelect, aliceOption.value);
|
||||
const aliceId = Number(aliceOption.value);
|
||||
@@ -278,7 +345,12 @@ describe('DevNotificationsPanel', () => {
|
||||
|
||||
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 /></>);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
expect(screen.queryByText('User-Scoped Events')).not.toBeInTheDocument();
|
||||
@@ -287,9 +359,14 @@ describe('DevNotificationsPanel', () => {
|
||||
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()),
|
||||
http.get('/api/admin/users', () => HttpResponse.error())
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
|
||||
expect(await screen.findByText('Type Testing')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument());
|
||||
@@ -298,7 +375,12 @@ describe('DevNotificationsPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-016: a trigger takes its hover background from CSS, not inline styles', async () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<DevNotificationsPanel />
|
||||
</>
|
||||
);
|
||||
await screen.findByText('Type Testing');
|
||||
|
||||
const btn = screen.getByText('Simple → Me').closest('button')!;
|
||||
|
||||
@@ -1,59 +1,147 @@
|
||||
import React, { useId } from 'react'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT } from '../shared/dialogParts'
|
||||
import { SettingsCard, StatusPill } from '../Settings/settingsKit'
|
||||
import { useDevNotifications } from './useDevNotifications'
|
||||
import {
|
||||
Bell, Zap, CheckCircle, Navigation, User, Calendar, Clock, Image, MessageSquare, Tag, UserPlus,
|
||||
Download, MapPin, ChevronDown, FlaskConical, Loader2, Map as MapIcon, ShieldCheck,
|
||||
} from 'lucide-react'
|
||||
Bell,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Download,
|
||||
FlaskConical,
|
||||
Image,
|
||||
Loader2,
|
||||
Map as MapIcon,
|
||||
MapPin,
|
||||
MessageSquare,
|
||||
Navigation,
|
||||
ShieldCheck,
|
||||
Tag,
|
||||
User,
|
||||
UserPlus,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import React, { useEffect, useId, useState } from 'react';
|
||||
import { adminApi, tripsApi } from '../../api/client';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { getApiErrorMessage } from '../../utils/apiError';
|
||||
import { SettingsCard, StatusPill } from '../Settings/settingsKit';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { EditorField, INPUT } from '../shared/dialogParts';
|
||||
|
||||
interface Trip {
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface AppUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/** A native select in the editors' box look, with the chevron the CustomSelect draws. */
|
||||
const SELECT = `${INPUT} appearance-none pe-9`
|
||||
const SELECT = `${INPUT} appearance-none pr-9`;
|
||||
|
||||
function SelectBox({ id, children, ...rest }: React.SelectHTMLAttributes<HTMLSelectElement>): React.ReactElement {
|
||||
return (
|
||||
<div className="relative">
|
||||
<select id={id} {...rest} className={SELECT}>{children}</select>
|
||||
<ChevronDown size={14} strokeWidth={2.2} className="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-content-faint" />
|
||||
<select id={id} {...rest} className={SELECT}>
|
||||
{children}
|
||||
</select>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
strokeWidth={2.2}
|
||||
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-content-faint"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** The buttons of one group, two to a row from a small window up. */
|
||||
function ButtonGrid({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">{children}</div>
|
||||
return <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">{children}</div>;
|
||||
}
|
||||
|
||||
export default function DevNotificationsPanel(): React.ReactElement {
|
||||
const ids = useId()
|
||||
const {
|
||||
sending, trips, selectedTripId, setSelectedTripId, users, selectedUserId, setSelectedUserId,
|
||||
tripInviteId, vacayInviteId, send,
|
||||
} = useDevNotifications()
|
||||
const toast = useToast();
|
||||
const ids = useId();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [sending, setSending] = useState<string | null>(null);
|
||||
const [trips, setTrips] = useState<Trip[]>([]);
|
||||
const [selectedTripId, setSelectedTripId] = useState<number | null>(null);
|
||||
const [users, setUsers] = useState<AppUser[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
tripsApi
|
||||
.list()
|
||||
.then((data) => {
|
||||
const list = (data.trips || data || []) as Trip[];
|
||||
setTrips(list);
|
||||
if (list.length > 0) setSelectedTripId(list[0].id);
|
||||
})
|
||||
.catch(() => {});
|
||||
adminApi
|
||||
.users()
|
||||
.then((data) => {
|
||||
const list = (data.users || data || []) as AppUser[];
|
||||
setUsers(list);
|
||||
if (list.length > 0) setSelectedUserId(list[0].id);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const fire = async (label: string, payload: Record<string, unknown>) => {
|
||||
setSending(label);
|
||||
try {
|
||||
await adminApi.sendTestNotification(payload);
|
||||
toast.success(`Sent: ${label}`);
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, 'Failed'));
|
||||
} finally {
|
||||
setSending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedTrip = trips.find((t) => t.id === selectedTripId);
|
||||
const selectedUser = users.find((u) => u.id === selectedUserId);
|
||||
const username = user?.username || 'Admin';
|
||||
const tripTitle = selectedTrip?.title || 'Test Trip';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const Btn = ({
|
||||
id, label, sub, icon: Icon, onClick,
|
||||
id,
|
||||
label,
|
||||
sub,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
}: {
|
||||
id: string; label: string; sub: string; icon: React.ElementType; onClick: () => void
|
||||
id: string;
|
||||
label: string;
|
||||
sub: string;
|
||||
icon: React.ElementType;
|
||||
onClick: () => void;
|
||||
}) => (
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={sending !== null}
|
||||
className="flex w-full min-w-0 items-center gap-3 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5 text-start transition-colors hover:bg-surface-secondary disabled:cursor-default"
|
||||
className="flex w-full min-w-0 items-center gap-3 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5 text-left transition-colors hover:bg-surface-secondary disabled:cursor-default"
|
||||
>
|
||||
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary">
|
||||
<Icon size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium text-content" style={fs(13, 'body')}>{label}</span>
|
||||
<span className="block truncate font-geist text-content-faint" style={fs(11)}>{sub}</span>
|
||||
<span className="block truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="block truncate font-geist text-content-faint" style={fs(11)}>
|
||||
{sub}
|
||||
</span>
|
||||
</span>
|
||||
{sending === id && <Loader2 size={15} className="flex-none animate-spin text-content-faint" />}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -66,48 +154,176 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
badge={<StatusPill tone="warning">DEV ONLY</StatusPill>}
|
||||
>
|
||||
<ButtonGrid>
|
||||
<Btn id="simple-me" label="Simple → Me" sub="test_simple · user" icon={Bell}
|
||||
onClick={send.simpleMe}
|
||||
<Btn
|
||||
id="simple-me"
|
||||
label="Simple → Me"
|
||||
sub="test_simple · user"
|
||||
icon={Bell}
|
||||
onClick={() =>
|
||||
fire('simple-me', {
|
||||
event: 'test_simple',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Btn id="boolean-me" label="Boolean → Me" sub="test_boolean · user" icon={CheckCircle}
|
||||
onClick={send.booleanMe}
|
||||
<Btn
|
||||
id="boolean-me"
|
||||
label="Boolean → Me"
|
||||
sub="test_boolean · user"
|
||||
icon={CheckCircle}
|
||||
onClick={() =>
|
||||
fire('boolean-me', {
|
||||
event: 'test_boolean',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
inApp: {
|
||||
type: 'boolean',
|
||||
positiveCallback: { action: 'test_approve', payload: {} },
|
||||
negativeCallback: { action: 'test_deny', payload: {} },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Btn id="navigate-me" label="Navigate → Me" sub="test_navigate · user" icon={Navigation}
|
||||
onClick={send.navigateMe}
|
||||
<Btn
|
||||
id="navigate-me"
|
||||
label="Navigate → Me"
|
||||
sub="test_navigate · user"
|
||||
icon={Navigation}
|
||||
onClick={() =>
|
||||
fire('navigate-me', {
|
||||
event: 'test_navigate',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Btn id="simple-admins" label="Simple → All Admins" sub="test_simple · admin" icon={Zap}
|
||||
onClick={send.simpleAdmins}
|
||||
<Btn
|
||||
id="simple-admins"
|
||||
label="Simple → All Admins"
|
||||
sub="test_simple · admin"
|
||||
icon={Zap}
|
||||
onClick={() =>
|
||||
fire('simple-admins', {
|
||||
event: 'test_simple',
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ButtonGrid>
|
||||
</SettingsCard>
|
||||
|
||||
{/* ── Trip-Scoped Events ───────────────────────────────────────────── */}
|
||||
{trips.length > 0 && (
|
||||
<SettingsCard icon={MapIcon} title="Trip-Scoped Events" hint="Fires each trip event to all members of the selected trip (excluding yourself).">
|
||||
<SettingsCard
|
||||
icon={MapIcon}
|
||||
title="Trip-Scoped Events"
|
||||
hint="Fires each trip event to all members of the selected trip (excluding yourself)."
|
||||
>
|
||||
<EditorField label="Trip" htmlFor={`${ids}-trip`}>
|
||||
<SelectBox
|
||||
id={`${ids}-trip`}
|
||||
value={selectedTripId ?? ''}
|
||||
onChange={e => setSelectedTripId(Number(e.target.value))}
|
||||
onChange={(e) => setSelectedTripId(Number(e.target.value))}
|
||||
>
|
||||
{trips.map(trip => <option key={trip.id} value={trip.id}>{trip.title}</option>)}
|
||||
{trips.map((trip) => (
|
||||
<option key={trip.id} value={trip.id}>
|
||||
{trip.title}
|
||||
</option>
|
||||
))}
|
||||
</SelectBox>
|
||||
</EditorField>
|
||||
<ButtonGrid>
|
||||
<Btn id="booking_change" label="booking_change" sub="navigate · trip" icon={Calendar}
|
||||
onClick={send.bookingChange}
|
||||
<Btn
|
||||
id="booking_change"
|
||||
label="booking_change"
|
||||
sub="navigate · trip"
|
||||
icon={Calendar}
|
||||
onClick={() =>
|
||||
selectedTripId &&
|
||||
fire('booking_change', {
|
||||
event: 'booking_change',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: {
|
||||
actor: username,
|
||||
trip: tripTitle,
|
||||
booking: 'Test Hotel',
|
||||
type: 'hotel',
|
||||
tripId: String(selectedTripId),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Btn id="trip_reminder" label="trip_reminder" sub="navigate · trip" icon={Clock}
|
||||
onClick={send.tripReminder}
|
||||
<Btn
|
||||
id="trip_reminder"
|
||||
label="trip_reminder"
|
||||
sub="navigate · trip"
|
||||
icon={Clock}
|
||||
onClick={() =>
|
||||
selectedTripId &&
|
||||
fire('trip_reminder', {
|
||||
event: 'trip_reminder',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { trip: tripTitle, tripId: String(selectedTripId) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Btn id="photos_shared" label="photos_shared" sub="navigate · trip" icon={Image}
|
||||
onClick={send.photosShared}
|
||||
<Btn
|
||||
id="photos_shared"
|
||||
label="photos_shared"
|
||||
sub="navigate · trip"
|
||||
icon={Image}
|
||||
onClick={() =>
|
||||
selectedTripId &&
|
||||
fire('photos_shared', {
|
||||
event: 'photos_shared',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Btn id="collab_message" label="collab_message" sub="navigate · trip" icon={MessageSquare}
|
||||
onClick={send.collabMessage}
|
||||
<Btn
|
||||
id="collab_message"
|
||||
label="collab_message"
|
||||
sub="navigate · trip"
|
||||
icon={MessageSquare}
|
||||
onClick={() =>
|
||||
selectedTripId &&
|
||||
fire('collab_message', {
|
||||
event: 'collab_message',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: {
|
||||
actor: username,
|
||||
trip: tripTitle,
|
||||
preview: 'This is a test message preview.',
|
||||
tripId: String(selectedTripId),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Btn id="packing_tagged" label="packing_tagged" sub="navigate · trip" icon={Tag}
|
||||
onClick={send.packingTagged}
|
||||
<Btn
|
||||
id="packing_tagged"
|
||||
label="packing_tagged"
|
||||
sub="navigate · trip"
|
||||
icon={Tag}
|
||||
onClick={() =>
|
||||
selectedTripId &&
|
||||
fire('packing_tagged', {
|
||||
event: 'packing_tagged',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ButtonGrid>
|
||||
</SettingsCard>
|
||||
@@ -120,25 +336,50 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
<SelectBox
|
||||
id={`${ids}-user`}
|
||||
value={selectedUserId ?? ''}
|
||||
onChange={e => setSelectedUserId(Number(e.target.value))}
|
||||
onChange={(e) => setSelectedUserId(Number(e.target.value))}
|
||||
>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.email})</option>)}
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username} ({u.email})
|
||||
</option>
|
||||
))}
|
||||
</SelectBox>
|
||||
</EditorField>
|
||||
<ButtonGrid>
|
||||
<Btn
|
||||
id={tripInviteId}
|
||||
id={`trip_invite-${selectedUserId}`}
|
||||
label="trip_invite"
|
||||
sub="navigate · user"
|
||||
icon={UserPlus}
|
||||
onClick={send.tripInvite}
|
||||
onClick={() =>
|
||||
selectedUserId &&
|
||||
fire(`trip_invite-${selectedUserId}`, {
|
||||
event: 'trip_invite',
|
||||
scope: 'user',
|
||||
targetId: selectedUserId,
|
||||
params: {
|
||||
actor: username,
|
||||
trip: tripTitle,
|
||||
invitee: selectedUser?.email || '',
|
||||
tripId: String(selectedTripId ?? 0),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Btn
|
||||
id={vacayInviteId}
|
||||
id={`vacay_invite-${selectedUserId}`}
|
||||
label="vacay_invite"
|
||||
sub="navigate · user"
|
||||
icon={MapPin}
|
||||
onClick={send.vacayInvite}
|
||||
onClick={() =>
|
||||
selectedUserId &&
|
||||
fire(`vacay_invite-${selectedUserId}`, {
|
||||
event: 'vacay_invite',
|
||||
scope: 'user',
|
||||
targetId: selectedUserId,
|
||||
params: { actor: username, planId: '1' },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ButtonGrid>
|
||||
</SettingsCard>
|
||||
@@ -147,11 +388,22 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
{/* ── Admin-Scoped Events ──────────────────────────────────────────── */}
|
||||
<SettingsCard icon={ShieldCheck} title="Admin-Scoped Events" hint="Fires to all admin users.">
|
||||
<ButtonGrid>
|
||||
<Btn id="version_available" label="version_available" sub="navigate · admin" icon={Download}
|
||||
onClick={send.versionAvailable}
|
||||
<Btn
|
||||
id="version_available"
|
||||
label="version_available"
|
||||
sub="navigate · admin"
|
||||
icon={Download}
|
||||
onClick={() =>
|
||||
fire('version_available', {
|
||||
event: 'version_available',
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: { version: '9.9.9-test' },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ButtonGrid>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// FE-ADMIN-GH-001 to FE-ADMIN-GH-017
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
|
||||
// FE-ADMIN-GH-001 to FE-ADMIN-GH-016
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import GitHubPanel from './GitHubPanel';
|
||||
|
||||
@@ -21,18 +21,12 @@ function buildRelease(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
const PAGE_1 = Array.from({ length: 10 }, (_, i) =>
|
||||
buildRelease({ id: i + 1, tag_name: `v1.${i}.0` }),
|
||||
);
|
||||
const PAGE_2 = Array.from({ length: 5 }, (_, i) =>
|
||||
buildRelease({ id: 100 + i, tag_name: `v0.${i}.0` }),
|
||||
);
|
||||
const PAGE_1 = Array.from({ length: 10 }, (_, i) => buildRelease({ id: i + 1, tag_name: `v1.${i}.0` }));
|
||||
const PAGE_2 = Array.from({ length: 5 }, (_, i) => buildRelease({ id: 100 + i, tag_name: `v0.${i}.0` }));
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([])),
|
||||
);
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([])));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -42,9 +36,7 @@ afterEach(() => {
|
||||
describe('GitHubPanel', () => {
|
||||
it('FE-ADMIN-GH-001: support link cards always render', async () => {
|
||||
render(<GitHubPanel />);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('Ko-fi')).toBeInTheDocument();
|
||||
expect(screen.getByText('Buy Me a Coffee')).toBeInTheDocument();
|
||||
expect(screen.getByText('Discord')).toBeInTheDocument();
|
||||
@@ -78,7 +70,7 @@ describe('GitHubPanel', () => {
|
||||
http.get('/api/admin/github-releases', async () => {
|
||||
await new Promise(() => {}); // never resolves
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
})
|
||||
);
|
||||
render(<GitHubPanel />);
|
||||
// The Loader2 spinner is rendered while loading=true
|
||||
@@ -89,8 +81,8 @@ describe('GitHubPanel', () => {
|
||||
it('FE-ADMIN-GH-004: error state shown on API failure', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () =>
|
||||
HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 }),
|
||||
),
|
||||
HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('Failed to load releases');
|
||||
@@ -101,9 +93,7 @@ describe('GitHubPanel', () => {
|
||||
it('FE-ADMIN-GH-005: releases render in timeline', async () => {
|
||||
const r1 = buildRelease({ id: 1, tag_name: 'v1.0.0', author: { login: 'mauriceboe' } });
|
||||
const r2 = buildRelease({ id: 2, tag_name: 'v1.1.0', author: { login: 'mauriceboe' } });
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])),
|
||||
);
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])));
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.0.0');
|
||||
expect(screen.getByText('v1.1.0')).toBeInTheDocument();
|
||||
@@ -112,16 +102,16 @@ describe('GitHubPanel', () => {
|
||||
expect(authorLabels.length).toBeGreaterThan(0);
|
||||
// Some date should be visible (non-empty)
|
||||
const dateEls = document.querySelectorAll('[class*="text-"]');
|
||||
const dateTexts = Array.from(dateEls).map(el => el.textContent).filter(t => t && t.match(/\d{4}/));
|
||||
const dateTexts = Array.from(dateEls)
|
||||
.map((el) => el.textContent)
|
||||
.filter((t) => t && t.match(/\d{4}/));
|
||||
expect(dateTexts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-006: latest badge shown only on first release', async () => {
|
||||
const r1 = buildRelease({ id: 1, tag_name: 'v2.0.0' });
|
||||
const r2 = buildRelease({ id: 2, tag_name: 'v1.9.0' });
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])),
|
||||
);
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])));
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v2.0.0');
|
||||
const latestBadges = screen.getAllByText('Latest');
|
||||
@@ -130,9 +120,7 @@ describe('GitHubPanel', () => {
|
||||
|
||||
it('FE-ADMIN-GH-007: prerelease badge shown', async () => {
|
||||
const r = buildRelease({ id: 10, tag_name: 'v3.0.0-beta.1', prerelease: true });
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||
);
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
|
||||
render(<GitHubPanel isPrerelease={true} />);
|
||||
await screen.findByText('v3.0.0-beta.1');
|
||||
expect(screen.getByText('Pre-release')).toBeInTheDocument();
|
||||
@@ -144,9 +132,7 @@ describe('GitHubPanel', () => {
|
||||
tag_name: 'v1.5.0',
|
||||
body: '- Fixed bug\n- Another fix',
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||
);
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
|
||||
const user = userEvent.setup();
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.5.0');
|
||||
@@ -164,9 +150,7 @@ describe('GitHubPanel', () => {
|
||||
|
||||
// Collapse
|
||||
await user.click(screen.getByText('Hide details'));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Fixed bug')).not.toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByText('Fixed bug')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('Show details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -176,9 +160,7 @@ describe('GitHubPanel', () => {
|
||||
tag_name: 'v1.6.0',
|
||||
body: '- list item\n- **bold text**\n- `inline code`',
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||
);
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
|
||||
const user = userEvent.setup();
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.6.0');
|
||||
@@ -201,18 +183,14 @@ describe('GitHubPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-010: "Load more" button visible when full page returned', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_1)),
|
||||
);
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_1)));
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText(`v1.0.0`);
|
||||
expect(screen.getByText('Load more')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-011: "Load more" hidden when partial page returned', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_2)),
|
||||
);
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_2)));
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v0.0.0');
|
||||
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
|
||||
@@ -224,9 +202,7 @@ describe('GitHubPanel', () => {
|
||||
tag_name: 'v1.7.0',
|
||||
body: 'This is a plain paragraph without any markdown syntax.',
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||
);
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
|
||||
const user = userEvent.setup();
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.7.0');
|
||||
@@ -240,9 +216,7 @@ describe('GitHubPanel', () => {
|
||||
tag_name: 'v1.8.0',
|
||||
body: '- [click here](https://example.com)',
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||
);
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
|
||||
const user = userEvent.setup();
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.8.0');
|
||||
@@ -257,9 +231,7 @@ describe('GitHubPanel', () => {
|
||||
tag_name: 'v1.9.0',
|
||||
body: '- [evil](javascript:alert(1))',
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||
);
|
||||
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
|
||||
const user = userEvent.setup();
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.9.0');
|
||||
@@ -311,7 +283,7 @@ describe('GitHubPanel', () => {
|
||||
return HttpResponse.json(PAGE_2);
|
||||
}
|
||||
return HttpResponse.json(PAGE_1);
|
||||
}),
|
||||
})
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<GitHubPanel />);
|
||||
@@ -333,31 +305,4 @@ describe('GitHubPanel', () => {
|
||||
// Load more should be hidden (PAGE_2 < 10)
|
||||
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-017: a failed "Load more" keeps the list and the retry loads the page', async () => {
|
||||
let failPage2 = true;
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', ({ request }) => {
|
||||
const page = new URL(request.url).searchParams.get('page');
|
||||
if (page === '2') {
|
||||
if (failPage2) return HttpResponse.json({ error: 'rate limited' }, { status: 500 });
|
||||
return HttpResponse.json(PAGE_2);
|
||||
}
|
||||
return HttpResponse.json(PAGE_1);
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.0.0');
|
||||
|
||||
await user.click(screen.getByText('Load more'));
|
||||
await screen.findByText(/Failed to load releases/);
|
||||
expect(screen.getAllByText(/v1\.\d\.0/).length).toBe(10);
|
||||
|
||||
failPage2 = false;
|
||||
await user.click(screen.getByText('Load more'));
|
||||
await screen.findByText('v0.0.0');
|
||||
expect(screen.getAllByText(/^v[01]\.\d\.0$/).length).toBe(15);
|
||||
expect(screen.queryByText(/Failed to load releases/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,14 +13,26 @@ import {
|
||||
Loader2,
|
||||
Tag,
|
||||
} from 'lucide-react';
|
||||
import { type ComponentType, type CSSProperties, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { escapeReleaseHtml as escapeHtml, parseReleaseNotes } from '../../utils/releaseNotes';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { useEffect, useState, type ComponentType, type CSSProperties, type ReactNode } from 'react';
|
||||
import apiClient from '../../api/client';
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n';
|
||||
import { SETTINGS_BUTTON, SettingsCard, StatusPill } from '../Settings/settingsKit';
|
||||
import { useGithubReleases } from './useGithubReleases';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
|
||||
const REPO = 'liketrek/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;
|
||||
}
|
||||
|
||||
/** The Discord mark, sized and coloured like the lucide glyphs beside it. */
|
||||
function DiscordIcon({ size = 18 }: { size?: number }) {
|
||||
@@ -58,24 +70,89 @@ function SupportCard({ link }: { link: SupportLink }) {
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-content" style={fs(13.5, 'body')}>{link.title}</span>
|
||||
<span className="block truncate text-content-faint" style={fs(11.5)}>{link.hint}</span>
|
||||
<span className="block truncate font-semibold text-content" style={fs(13.5, 'body')}>
|
||||
{link.title}
|
||||
</span>
|
||||
<span className="block truncate text-content-faint" style={fs(11.5)}>
|
||||
{link.hint}
|
||||
</span>
|
||||
</span>
|
||||
<ExternalLink size={14} className="flex-none text-content-faint transition-colors group-hover:text-content-muted" />
|
||||
<ExternalLink
|
||||
size={14}
|
||||
className="flex-none text-content-faint transition-colors group-hover:text-content-muted"
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
releases, shownReleases: shown, loading, error, expanded, toggleExpand, hasMore, loadingMore, handleLoadMore, formatDate,
|
||||
} = useGithubReleases({ isPrerelease });
|
||||
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);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
void fetchReleases(1).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
const next = page + 1;
|
||||
setLoadingMore(true);
|
||||
await fetchReleases(next, true);
|
||||
setPage(next);
|
||||
setLoadingMore(false);
|
||||
};
|
||||
|
||||
const toggleExpand = (id: number) => {
|
||||
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
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: string) => {
|
||||
if (!body) return null;
|
||||
const lines = body.split('\n');
|
||||
const elements: ReactNode[] = [];
|
||||
let listItems: string[] = [];
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length > 0) {
|
||||
elements.push(
|
||||
<ul key={`ul-${elements.length}`} className="my-1.5 space-y-1 pl-0">
|
||||
{listItems.map((item, i) => (
|
||||
<li key={i} className="flex gap-2 leading-relaxed text-content-secondary" style={fs(12.5, 'body')}>
|
||||
<span className="mt-[0.6em] h-1 w-1 flex-shrink-0 rounded-full bg-content-faint" />
|
||||
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
listItems = [];
|
||||
}
|
||||
};
|
||||
|
||||
const escapeHtml = (str: string) =>
|
||||
str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const inlineFormat = (text: string) => {
|
||||
return escapeHtml(text)
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
@@ -89,42 +166,47 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
});
|
||||
};
|
||||
|
||||
return parseReleaseNotes(body).map((block, idx) => {
|
||||
if (block.kind === 'ul') {
|
||||
return (
|
||||
<ul key={`ul-${idx}`} className="my-1.5 space-y-1 ps-0">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i} className="flex gap-2 leading-relaxed text-content-secondary" style={fs(12.5, 'body')}>
|
||||
<span className="mt-[0.6em] h-1 w-1 flex-shrink-0 rounded-full bg-content-faint" />
|
||||
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
flushList();
|
||||
continue;
|
||||
}
|
||||
if (block.kind === 'h4') {
|
||||
return (
|
||||
<h4 key={idx} className="mb-1 mt-3 font-geist font-bold uppercase tracking-[.08em] text-content-faint first:mt-0" style={fs(10)}>
|
||||
{block.text}
|
||||
|
||||
if (trimmed.startsWith('### ')) {
|
||||
flushList();
|
||||
elements.push(
|
||||
<h4
|
||||
key={elements.length}
|
||||
className="mb-1 mt-3 font-geist font-bold uppercase tracking-[.08em] text-content-faint first:mt-0"
|
||||
style={fs(10)}
|
||||
>
|
||||
{trimmed.slice(4)}
|
||||
</h4>
|
||||
);
|
||||
}
|
||||
if (block.kind === 'h3') {
|
||||
return (
|
||||
<h3 key={idx} className="mb-1 mt-3 font-semibold text-content first:mt-0" style={fs(13, 'body')}>
|
||||
{block.text}
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
flushList();
|
||||
elements.push(
|
||||
<h3 key={elements.length} className="mb-1 mt-3 font-semibold text-content first:mt-0" style={fs(13, 'body')}>
|
||||
{trimmed.slice(3)}
|
||||
</h3>
|
||||
);
|
||||
} else if (/^[-*] /.test(trimmed)) {
|
||||
listItems.push(trimmed.slice(2));
|
||||
} else {
|
||||
flushList();
|
||||
elements.push(
|
||||
<p
|
||||
key={elements.length}
|
||||
className="my-1 leading-relaxed text-content-secondary"
|
||||
style={fs(12.5, 'body')}
|
||||
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p
|
||||
key={idx}
|
||||
className="my-1 leading-relaxed text-content-secondary"
|
||||
style={fs(12.5, 'body')}
|
||||
dangerouslySetInnerHTML={{ __html: inlineFormat(block.text) }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
flushList();
|
||||
return elements;
|
||||
};
|
||||
|
||||
const supportLinks: SupportLink[] = [
|
||||
@@ -183,9 +265,10 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
} else if (error && releases.length === 0) {
|
||||
} else if (error) {
|
||||
releasesCard = <SettingsCard icon={AlertTriangle} tone="danger" title={t('admin.github.error')} hint={error} />;
|
||||
} else {
|
||||
const shown = isPrerelease ? releases : releases.filter((r) => !r.prerelease);
|
||||
releasesCard = (
|
||||
<SettingsCard
|
||||
icon={History}
|
||||
@@ -207,7 +290,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
{/* Timeline */}
|
||||
<div className="relative">
|
||||
{/* Timeline line, from the first dot's middle down to the last one's */}
|
||||
{shown.length > 1 && <div className="absolute bottom-6 start-[15px] top-4 w-px bg-edge" aria-hidden="true" />}
|
||||
{shown.length > 1 && <div className="absolute bottom-6 left-[15px] top-4 w-px bg-edge" aria-hidden="true" />}
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
{shown.map((release, idx) => {
|
||||
@@ -219,7 +302,9 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
{/* Timeline dot */}
|
||||
<span
|
||||
className={`relative grid h-8 w-8 flex-none place-items-center rounded-[10px] ${
|
||||
isLatest ? 'bg-accent text-accent-text' : 'bg-surface-card text-content-faint shadow-sm ring-1 ring-edge-faint'
|
||||
isLatest
|
||||
? 'bg-accent text-accent-text'
|
||||
: 'bg-surface-card text-content-faint shadow-sm ring-1 ring-edge-faint'
|
||||
}`}
|
||||
>
|
||||
<Tag size={13} strokeWidth={2.2} />
|
||||
@@ -228,16 +313,23 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
{/* Release content */}
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-geist font-bold tabular-nums text-content" style={fs(14, 'body')}>{release.tag_name}</span>
|
||||
<span className="font-geist font-bold tabular-nums text-content" style={fs(14, 'body')}>
|
||||
{release.tag_name}
|
||||
</span>
|
||||
{isLatest && <StatusPill tone="success">{t('admin.github.latest')}</StatusPill>}
|
||||
{release.prerelease && <StatusPill tone="warning">{t('admin.github.prerelease')}</StatusPill>}
|
||||
</div>
|
||||
|
||||
{release.name && release.name !== release.tag_name && (
|
||||
<p className="m-0 mt-0.5 truncate font-medium text-content-secondary" style={fs(12.5, 'body')}>{release.name}</p>
|
||||
<p className="m-0 mt-0.5 truncate font-medium text-content-secondary" style={fs(12.5, 'body')}>
|
||||
{release.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-content-faint" style={fs(11.5)}>
|
||||
<div
|
||||
className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-content-faint"
|
||||
style={fs(11.5)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1 font-geist tabular-nums text-content-faint">
|
||||
<Calendar size={11} />
|
||||
{formatDate(release.published_at || release.created_at)}
|
||||
@@ -252,13 +344,18 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
{/* Expandable body */}
|
||||
{release.body && (
|
||||
<div className="mt-2">
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
aria-expanded={!!isExpanded}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-surface-tertiary px-2.5 py-1 font-semibold text-content-muted transition-colors hover:text-content"
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={12} strokeWidth={2.4} /> : <ChevronDown size={12} strokeWidth={2.4} />}
|
||||
{isExpanded ? (
|
||||
<ChevronUp size={12} strokeWidth={2.4} />
|
||||
) : (
|
||||
<ChevronDown size={12} strokeWidth={2.4} />
|
||||
)}
|
||||
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
||||
</button>
|
||||
|
||||
@@ -276,23 +373,21 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* A failed "Load more" keeps the timeline and stays retryable */}
|
||||
{error && (
|
||||
<p className="m-0 text-center text-content-faint" style={fs(11.5)}>
|
||||
{t('admin.github.error')}: {error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center">
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{loadingMore ? <Loader2 size={13} className="animate-spin" /> : <ChevronDown size={13} strokeWidth={2.2} />}
|
||||
{loadingMore ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<ChevronDown size={13} strokeWidth={2.2} />
|
||||
)}
|
||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -306,7 +401,9 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
{/* Support links: one card, the six places as tiles inside it */}
|
||||
<SettingsCard icon={Heart} title={t('settings.about')}>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{supportLinks.map((link) => <SupportCard key={link.href} link={link} />)}
|
||||
{supportLinks.map((link) => (
|
||||
<SupportCard key={link.href} link={link} />
|
||||
))}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
|
||||
@@ -1,58 +1,70 @@
|
||||
// FE-ADMIN-GQUOTA-001 to FE-ADMIN-GQUOTA-004
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import GoogleDailyLimitRow from './GoogleDailyLimitRow'
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import GoogleDailyLimitRow from './GoogleDailyLimitRow';
|
||||
|
||||
describe('GoogleDailyLimitRow (#1582)', () => {
|
||||
it('FE-ADMIN-GQUOTA-001: shows the stored ceiling and today against it', async () => {
|
||||
server.use(http.get('/api/admin/google-quota', () => HttpResponse.json({ daily_limit: 500, used_today: 123, exhausted: false })))
|
||||
render(<GoogleDailyLimitRow />)
|
||||
expect(await screen.findByText('Today: 123 of 500')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Daily limit for Google calls')).toHaveValue('500')
|
||||
server.use(
|
||||
http.get('/api/admin/google-quota', () =>
|
||||
HttpResponse.json({ daily_limit: 500, used_today: 123, exhausted: false })
|
||||
)
|
||||
);
|
||||
render(<GoogleDailyLimitRow />);
|
||||
expect(await screen.findByText('Today: 123 of 500')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Daily limit for Google calls')).toHaveValue('500');
|
||||
// Nothing to save until the number changes.
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).toBeNull()
|
||||
})
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GQUOTA-002: without a ceiling it counts today only', async () => {
|
||||
server.use(http.get('/api/admin/google-quota', () => HttpResponse.json({ daily_limit: null, used_today: 7, exhausted: false })))
|
||||
render(<GoogleDailyLimitRow />)
|
||||
expect(await screen.findByText('Today: 7')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('No limit')).toHaveValue('')
|
||||
})
|
||||
server.use(
|
||||
http.get('/api/admin/google-quota', () =>
|
||||
HttpResponse.json({ daily_limit: null, used_today: 7, exhausted: false })
|
||||
)
|
||||
);
|
||||
render(<GoogleDailyLimitRow />);
|
||||
expect(await screen.findByText('Today: 7')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('No limit')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GQUOTA-003: a used-up day says so', async () => {
|
||||
server.use(http.get('/api/admin/google-quota', () => HttpResponse.json({ daily_limit: 10, used_today: 10, exhausted: true })))
|
||||
render(<GoogleDailyLimitRow />)
|
||||
expect(await screen.findByText('Limit reached (10), Google paused until tomorrow')).toBeInTheDocument()
|
||||
})
|
||||
server.use(
|
||||
http.get('/api/admin/google-quota', () => HttpResponse.json({ daily_limit: 10, used_today: 10, exhausted: true }))
|
||||
);
|
||||
render(<GoogleDailyLimitRow />);
|
||||
expect(await screen.findByText('Limit reached (10), Google paused until tomorrow')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GQUOTA-004: saving sends the new number, and an empty field sends null', async () => {
|
||||
const sent: unknown[] = []
|
||||
const sent: unknown[] = [];
|
||||
server.use(
|
||||
http.get('/api/admin/google-quota', () => HttpResponse.json({ daily_limit: 500, used_today: 3, exhausted: false })),
|
||||
http.get('/api/admin/google-quota', () =>
|
||||
HttpResponse.json({ daily_limit: 500, used_today: 3, exhausted: false })
|
||||
),
|
||||
http.put('/api/admin/google-quota', async ({ request }) => {
|
||||
const body = (await request.json()) as { daily_limit: number | null }
|
||||
sent.push(body)
|
||||
return HttpResponse.json({ daily_limit: body.daily_limit, used_today: 3, exhausted: false })
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
render(<GoogleDailyLimitRow />)
|
||||
const input = await screen.findByLabelText('Daily limit for Google calls')
|
||||
await waitFor(() => expect(input).toHaveValue('500'))
|
||||
const body = (await request.json()) as { daily_limit: number | null };
|
||||
sent.push(body);
|
||||
return HttpResponse.json({ daily_limit: body.daily_limit, used_today: 3, exhausted: false });
|
||||
})
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<GoogleDailyLimitRow />);
|
||||
const input = await screen.findByLabelText('Daily limit for Google calls');
|
||||
await waitFor(() => expect(input).toHaveValue('500'));
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, '800')
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(sent).toEqual([{ daily_limit: 800 }]))
|
||||
expect(await screen.findByText('Today: 3 of 800')).toBeInTheDocument()
|
||||
await user.clear(input);
|
||||
await user.type(input, '800');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(sent).toEqual([{ daily_limit: 800 }]));
|
||||
expect(await screen.findByText('Today: 3 of 800')).toBeInTheDocument();
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, '{Enter}')
|
||||
await waitFor(() => expect(sent).toEqual([{ daily_limit: 800 }, { daily_limit: null }]))
|
||||
expect(await screen.findByText('Today: 3')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
await user.clear(input);
|
||||
await user.type(input, '{Enter}');
|
||||
await waitFor(() => expect(sent).toEqual([{ daily_limit: 800 }, { daily_limit: null }]));
|
||||
expect(await screen.findByText('Today: 3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user