From 8b1a238c518fef5e141f91202040ce5fce8370b5 Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:48:01 +0200 Subject: [PATCH] fix(ui): sparkline stacking, wrapping sheet hint, translated phone entry view, Cloudflare docs (#1884) * Put the atlas sparkline behind the text it was covering The graphic is absolutely positioned and comes after the label, value and delta in the DOM, so with no stacking order of its own the browser painted it on top of them. On the dashboard's atlas row a status card is about 190px wide at 1440px, and the 80px sparkline covered more than half the delta line (#1787). Give it z-index 0 and lift the three text rows to 1. It is decoration, so it stops taking pointer events while we are here. * Let a sentence-length sheet subtitle wrap TileHeader truncates its subtitle to one line. That is right for a filename or a date, and wrong for the reorder hint: between the 40px icon tile and the 34px close button there is not enough width for a sentence, so it was cut off mid-word (#1814), and every translation is longer than the English one. Opt in with subWrap where the subtitle is prose; the file sheets stay single-line. * Translate the phone entry view MobileEntryView carried its own English label tables while the desktop view resolved the same values through i18n, so a German reader got "Amazing" and "Sunny" on a phone and the translated chips on a tablet (#1846). Which view appears is decided by two different breakpoints, which is why it looked arbitrary. The labels are keys now, the edit action, the photo counter and the pros/cons headings go through t(), and the date formats against the chosen locale instead of the browser default. No new keys: all of them already exist in all 23 files. The colour classes stay local. The desktop table carries raw hex values and no dark-mode variants, so sharing one table would change how this looks. * Name Google account linking in the Cloudflare bot section The section claimed the block was specific to ChatGPT, so someone hitting it with Gemini had no reason to read on (#1667). Google exchanges the code from its own servers, which Bot Fight Mode blocks the same way, and the symptom differs enough to be worth spelling out: the browser half succeeds and linking fails afterwards, with a 200 on /api/oauth/authorize and no /oauth/token at all. Also note that Bot Fight Mode ignores IP allowlists, which is the first thing people try. --- .../Journey/MobileEntryView.test.tsx | 24 ++++++++++- .../components/Journey/MobileEntryView.tsx | 40 +++++++++++-------- .../mobile/screens/trip/sheets/MDaysSheet.tsx | 1 + .../screens/trip/sheets/MTripSheetUi.tsx | 20 ++++++++-- client/src/styles/dashboard.css | 10 ++++- .../unit/mobile/trip/MDaysSheet.test.tsx | 8 ++++ wiki/Troubleshooting.md | 9 ++++- 7 files changed, 88 insertions(+), 24 deletions(-) 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)**