mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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.
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
|
||||
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<string, { icon: typeof Sun; label: string }> = {
|
||||
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 (
|
||||
<div className="fixed inset-0 z-[9999] bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
|
||||
@@ -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"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
Edit
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onClose(); onDelete(); }}
|
||||
@@ -94,7 +100,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
{photos.length > 1 && (
|
||||
<div className="absolute bottom-3 right-3 flex items-center gap-1 bg-black/60 backdrop-blur-sm text-white rounded-full px-2.5 py-1 text-[11px] font-medium">
|
||||
<Camera size={12} />
|
||||
{photos.length} photos
|
||||
{t('mobileJourney.photosCount', { count: photos.length })}
|
||||
</div>
|
||||
)}
|
||||
{/* Photo strip for multiple photos */}
|
||||
@@ -150,13 +156,13 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
{mood && (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold ${mood.bg} ${mood.text}`}>
|
||||
<mood.icon size={13} />
|
||||
{mood.label}
|
||||
{t(mood.label)}
|
||||
</span>
|
||||
)}
|
||||
{weather && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
|
||||
<weather.icon size={13} />
|
||||
{weather.label}
|
||||
{t(weather.label)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -186,7 +192,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
{prosArr.length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400 uppercase tracking-wide mb-2">
|
||||
<ThumbsUp size={12} /> Pros
|
||||
<ThumbsUp size={12} /> {t('journey.editor.pros')}
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{prosArr.map((p, i) => (
|
||||
@@ -203,7 +209,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
{consArr.length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-red-500 dark:text-red-400 uppercase tracking-wide mb-2">
|
||||
<ThumbsDown size={12} /> Cons
|
||||
<ThumbsDown size={12} /> {t('journey.editor.cons')}
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{consArr.map((c, i) => (
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function MDaysSheet({ planner, shell }: MTripSheetsProps) {
|
||||
icon={<CalendarRange size={19} strokeWidth={1.8} />}
|
||||
title={t('dayplan.reorderTitle')}
|
||||
sub={t('dayplan.reorderHint')}
|
||||
subWrap
|
||||
onClose={shell.closeSheet}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)]">
|
||||
@@ -34,7 +44,11 @@ export function TileHeader({ icon, title, sub, onClose, closeLabel }: TileHeader
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-[6px] text-[1.0625rem] font-bold leading-tight">{title}</div>
|
||||
{sub && <div className="truncate font-geist text-[0.71875rem] text-m-muted">{sub}</div>}
|
||||
{sub && (
|
||||
<div className={`font-geist text-[0.71875rem] text-m-muted ${subWrap ? 'leading-snug' : 'truncate'}`}>
|
||||
{sub}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MIconBtn variant="neutral" size={34} onClick={onClose} ariaLabel={closeLabel}>
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user