diff --git a/client/src/components/Journey/MobileEntryView.test.tsx b/client/src/components/Journey/MobileEntryView.test.tsx index 5873f47f3..c32288f93 100644 --- a/client/src/components/Journey/MobileEntryView.test.tsx +++ b/client/src/components/Journey/MobileEntryView.test.tsx @@ -1,7 +1,9 @@ // FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-017 import { render, screen, fireEvent } from '../../../tests/helpers/render' -import { resetAllStores } from '../../../tests/helpers/store' +import { resetAllStores, seedStore } from '../../../tests/helpers/store' +import { buildSettings } from '../../../tests/helpers/factories' +import { useSettingsStore } from '../../store/settingsStore' import MobileEntryView from './MobileEntryView' import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' @@ -212,4 +214,24 @@ describe('MobileEntryView', () => { expect(screen.queryByText('Pros')).not.toBeInTheDocument() expect(screen.queryByText('Cons')).not.toBeInTheDocument() }) + + // This view held its own English label tables while the desktop one resolved + // the same values through i18n, so a German reader got English chips on a + // phone and translated ones on a tablet (#1846). Which of the two appears is + // decided by two different breakpoints, which is why it looked random. + it('FE-COMP-JENTRYVIEW-018: mood, weather and the edit action follow the chosen language', async () => { + seedStore(useSettingsStore, { settings: buildSettings({ language: 'de' }) }) + renderView(buildEntry({ + mood: 'amazing', + weather: 'sunny', + pros_cons: { pros: ['gutes Licht'], cons: ['sehr kalt'] }, + })) + + // The locale bundle is fetched, so the first paint is still English. + expect(await screen.findByText('Großartig')).toBeInTheDocument() + expect(screen.getByText('Sonnig')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Bearbeiten/ })).toBeInTheDocument() + expect(screen.queryByText('Amazing')).not.toBeInTheDocument() + expect(screen.queryByText('Sunny')).not.toBeInTheDocument() + }) }) diff --git a/client/src/components/Journey/MobileEntryView.tsx b/client/src/components/Journey/MobileEntryView.tsx index d79264b47..08c8dcdc7 100644 --- a/client/src/components/Journey/MobileEntryView.tsx +++ b/client/src/components/Journey/MobileEntryView.tsx @@ -5,23 +5,28 @@ import { ThumbsUp, ThumbsDown, } from 'lucide-react' import JournalBody from './JournalBody' +import { useTranslation } from '../../i18n' import { formatLocationName } from '../../utils/formatters' import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' +// Labels are translation keys, like the desktop tables in +// pages/journeyDetail/JourneyDetailPage.constants.ts. The colour classes stay +// local because the desktop variant carries raw hex values and no dark-mode +// classes, so the two cannot share one table without changing how this looks. const MOOD_CONFIG: Record = { - amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' }, - good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' }, - neutral: { icon: Meh, label: 'Neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' }, - rough: { icon: Frown, label: 'Rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' }, + amazing: { icon: Laugh, label: 'journey.mood.amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' }, + good: { icon: Smile, label: 'journey.mood.good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' }, + neutral: { icon: Meh, label: 'journey.mood.neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' }, + rough: { icon: Frown, label: 'journey.mood.rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' }, } const WEATHER_CONFIG: Record = { - sunny: { icon: Sun, label: 'Sunny' }, - partly: { icon: CloudSun, label: 'Partly cloudy' }, - cloudy: { icon: Cloud, label: 'Cloudy' }, - rainy: { icon: CloudRain, label: 'Rainy' }, - stormy: { icon: CloudLightning, label: 'Stormy' }, - cold: { icon: Snowflake, label: 'Cold' }, + sunny: { icon: Sun, label: 'journey.weather.sunny' }, + partly: { icon: CloudSun, label: 'journey.weather.partly' }, + cloudy: { icon: Cloud, label: 'journey.weather.cloudy' }, + rainy: { icon: CloudRain, label: 'journey.weather.rainy' }, + stormy: { icon: CloudLightning, label: 'journey.weather.stormy' }, + cold: { icon: Snowflake, label: 'journey.weather.cold' }, } function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original', builder?: (id: number) => string): string { @@ -40,6 +45,7 @@ interface Props { } export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClose, onEdit, onDelete, onPhotoClick }: Props) { + const { t, locale } = useTranslation() const photos = entry.photos || [] const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null @@ -48,7 +54,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo const hasProscons = prosArr.length > 0 || consArr.length > 0 const date = new Date(entry.entry_date + 'T00:00:00') - const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' }) + const dateStr = date.toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long' }) return (
@@ -67,7 +73,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo className="h-8 px-3 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-[12px] font-medium flex items-center gap-1.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors" > - Edit + {t('common.edit')}
@@ -186,7 +192,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo {prosArr.length > 0 && (
- Pros + {t('journey.editor.pros')}
    {prosArr.map((p, i) => ( @@ -203,7 +209,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo {consArr.length > 0 && (
    - Cons + {t('journey.editor.cons')}
      {consArr.map((c, i) => ( diff --git a/client/src/mobile/screens/trip/sheets/MDaysSheet.tsx b/client/src/mobile/screens/trip/sheets/MDaysSheet.tsx index 22d94972c..32f33470b 100644 --- a/client/src/mobile/screens/trip/sheets/MDaysSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MDaysSheet.tsx @@ -44,6 +44,7 @@ export default function MDaysSheet({ planner, shell }: MTripSheetsProps) { icon={} title={t('dayplan.reorderTitle')} sub={t('dayplan.reorderHint')} + subWrap onClose={shell.closeSheet} closeLabel={t('common.close')} /> diff --git a/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx b/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx index 48ced590a..156587a5e 100644 --- a/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx +++ b/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx @@ -21,12 +21,22 @@ interface TileHeaderProps { icon: ReactNode title: ReactNode sub?: ReactNode + /** Let a sentence-length subtitle wrap instead of truncating it to one line. */ + subWrap?: boolean onClose: () => void closeLabel: string } -/** Sheet header: 40px icon tile + title/sub + 34px round close. */ -export function TileHeader({ icon, title, sub, onClose, closeLabel }: TileHeaderProps) { +/** + * Sheet header: 40px icon tile + title/sub + 34px round close. + * + * `sub` is truncated to one line by default, which is right for a filename or a + * date but wrong for a sentence: the icon tile and the close button leave so + * little width that a hint like "Hold and drag to reorder" was cut off mid-word + * (#1814), and it is longer still in most translations. Pass `subWrap` where the + * subtitle is prose. + */ +export function TileHeader({ icon, title, sub, subWrap = false, onClose, closeLabel }: TileHeaderProps) { return (
      @@ -34,7 +44,11 @@ export function TileHeader({ icon, title, sub, onClose, closeLabel }: TileHeader
      {title}
      - {sub &&
      {sub}
      } + {sub && ( +
      + {sub} +
      + )}
      diff --git a/client/src/styles/dashboard.css b/client/src/styles/dashboard.css index 7df23be7f..9e509b44a 100644 --- a/client/src/styles/dashboard.css +++ b/client/src/styles/dashboard.css @@ -384,7 +384,15 @@ .trek-dash .flag img { width: 100%; height: 100%; object-fit: cover; display: block; } .trek-dash .flag:first-child { margin-left: 0; } .trek-dash .flag.more { background: oklch(1 0 0 / .15); color: #fff; font-size: 10px; border: 2px solid oklch(0.28 0.04 50); } -.trek-dash .spark { position: absolute; right: 18px; bottom: 18px; opacity: .55; } +/* Behind the text, not over it (#1787). The sparkline is positioned and comes + after the label/value/delta in the DOM, so without a stacking order of its own + it painted on top of them — and on a narrow status card (the atlas row is + 1.5fr 1fr 1fr 1fr next to a 400px sidebar) the 80px graphic covers more than + half the line. Decoration, so it also stops swallowing pointer events. */ +.trek-dash .spark { position: absolute; right: 18px; bottom: 18px; opacity: .55; z-index: 0; pointer-events: none; } +.trek-dash .atlas-card .label, +.trek-dash .atlas-card .value, +.trek-dash .atlas-card .delta { position: relative; z-index: 1; } .trek-dash .spark polyline, .trek-dash .spark path, .trek-dash .spark circle:not([stroke]) { stroke: var(--ink); } diff --git a/client/tests/unit/mobile/trip/MDaysSheet.test.tsx b/client/tests/unit/mobile/trip/MDaysSheet.test.tsx index f2b14375f..2d0c19cab 100644 --- a/client/tests/unit/mobile/trip/MDaysSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MDaysSheet.test.tsx @@ -46,6 +46,14 @@ describe('MDaysSheet', () => { expect(screen.getByText("A day's places, notes and bookings move with it.")).toBeInTheDocument() }) + it('FE-MOB-DAYSS-003b: the hint wraps instead of being cut off mid-sentence', () => { + // The shared header truncates its subtitle to one line, which is right for a + // filename but cut this sentence off in every language (#1814). + renderSheet() + const hint = screen.getByText("A day's places, notes and bookings move with it.") + expect(hint).not.toHaveClass('truncate') + }) + it('FE-MOB-DAYSS-004: lists the days in day_number order and numbers the rows', () => { renderSheet() const positions = screen.getAllByText(/^[123]$/).map(el => el.textContent) diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md index 032dce41f..28d6ef815 100644 --- a/wiki/Troubleshooting.md +++ b/wiki/Troubleshooting.md @@ -407,11 +407,16 @@ the client (check that your reverse proxy forwards it). ## MCP requests blocked by Cloudflare WAF (Bot Fight Mode) -**Cause:** When TREK is proxied through Cloudflare, **Bot Fight Mode** and **Super Bot Fight Mode** classify requests from ChatGPT as bots and block them at the WAF level — before the request ever reaches TREK. This is specific to ChatGPT; Claude.ai is not affected. ChatGPT's exit node IPs have low reputation scores in Cloudflare's threat intelligence and the User-Agent matches Cloudflare's automated-traffic heuristics. TREK itself never receives the request, so there is nothing in TREK's logs; the block is silent from TREK's perspective. +**Cause:** When TREK is proxied through Cloudflare, **Bot Fight Mode** and **Super Bot Fight Mode** classify server-to-server requests as bots and block them at the WAF level — before the request ever reaches TREK. Their exit-node IPs have low reputation scores in Cloudflare's threat intelligence and the User-Agent matches Cloudflare's automated-traffic heuristics. TREK itself never receives the request, so there is nothing in TREK's logs; the block is silent from TREK's perspective. + +This affects **ChatGPT** and **Google account linking (Gemini, Assistant)**. Claude.ai is not affected. + +Note that Bot Fight Mode does **not** honour IP allowlists, so adding the provider's published ranges to a Cloudflare IP Access rule does not help; you need one of the two fixes below. Symptoms: - ChatGPT shows a connection error or times out immediately after OAuth completes. -- Cloudflare's Security → Events log shows blocked requests to `/mcp` with action `block` and source `bfm` (Bot Fight Mode) or `managed_rule`. +- With Google account linking the symptom looks different, and more confusing: the browser part succeeds (you approve the consent screen and are redirected back), then linking fails. Google exchanges the authorization code from its own servers, and that call is what gets blocked. In TREK's logs you see `POST /api/oauth/authorize` answered `200` and then **no** `POST /oauth/token` at all. +- Cloudflare's Security → Events log shows blocked requests to `/mcp` or `/oauth/token` with action `block` and source `bfm` (Bot Fight Mode) or `managed_rule`. **Fix — Option 1: Disable Bot Fight Mode (free plan and paid plan)**