Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c525b2f84c | |||
| b3d30a834e | |||
| 589d3354eb | |||
| 8ce3b24a01 | |||
| 06a1dcd730 | |||
| 4b633d8cd2 | |||
| 728a275fef | |||
| f6befaa6b2 | |||
| fd6d3fddd5 | |||
| 280e55bbc9 | |||
| e84060cdf7 | |||
| fbc85b05a1 | |||
| 01333c6ff3 | |||
| aecef03090 | |||
| 8dacd0a50c | |||
| 3c73da4427 | |||
| cff2e55db7 |
@@ -460,6 +460,20 @@ const exportDialog = (page: Page) => page.getByRole('dialog', { name: 'Export' }
|
||||
const thumbs = (page: Page) => page.locator('.st-thumb-row')
|
||||
/** The hit targets over the spread, one per element. */
|
||||
const sheetElements = (page: Page) => page.locator('.st-sheet > div[style*="left:"]')
|
||||
/**
|
||||
* The hit target of the first text on the spread on screen. The targets above the page are empty
|
||||
* boxes, so the one over the drawn words is found by where the words are and marked for the locator.
|
||||
*/
|
||||
async function markTextTarget(page: Page): Promise<void> {
|
||||
const words = page.locator('.st-sheet').getByText(/\p{L}{3,}/u).first()
|
||||
await expect(words).toBeVisible()
|
||||
await words.evaluate(el => {
|
||||
const r = el.getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
|
||||
hit?.setAttribute('data-help-text', '')
|
||||
})
|
||||
}
|
||||
const textTarget = (page: Page) => page.locator('.st-sheet [data-help-text]')
|
||||
|
||||
async function studioSection(page: Page, name: string): Promise<void> {
|
||||
await railButton(page, name).click()
|
||||
@@ -1458,16 +1472,162 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
],
|
||||
},
|
||||
|
||||
'studio-my-layouts': {
|
||||
guide: guide('studio-my-layouts'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => railButton(p, 'Layouts'),
|
||||
act: async p => { await studioSection(p, 'Layouts') },
|
||||
},
|
||||
{
|
||||
// Pointed at rather than pressed, so the seed book keeps no layout of its own for the guides after this one.
|
||||
prepare: async p => {
|
||||
await sidePanel(p).getByRole('button', { name: 'Save this page as a layout' }).scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => sidePanel(p).locator('.st-saved-layouts'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-theme': {
|
||||
guide: guide('studio-theme'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
target: p => railButton(p, 'Theme'),
|
||||
act: async p => { await studioSection(p, 'Theme') },
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-theme-grid'),
|
||||
act: async p => {
|
||||
await sidePanel(p).locator('.st-theme-card').nth(1).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await sidePanel(p).getByRole('button', { name: 'Apply to the book' }).scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => sidePanel(p).locator('.st-theme-apply'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-stickers': {
|
||||
guide: guide('studio-stickers'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => { await studioSection(p, 'Elements') },
|
||||
target: p => sidePanel(p).getByRole('tab', { name: 'Stickers' }),
|
||||
act: async p => {
|
||||
await sidePanel(p).getByRole('tab', { name: 'Stickers' }).click()
|
||||
await expect(sidePanel(p).locator('.st-shape-grid').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-shape-grid').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-travel': {
|
||||
guide: guide('studio-travel'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
target: p => railButton(p, 'Travel'),
|
||||
act: async p => { await studioSection(p, 'Travel') },
|
||||
act: async p => {
|
||||
await studioSection(p, 'Travel')
|
||||
await expect(sidePanel(p).locator('.st-travel-card').first()).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: sidePanel,
|
||||
target: p => sidePanel(p).locator('.st-travel-grid').first(),
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-travel-card').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-text-effects': {
|
||||
guide: guide('studio-text-effects'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
await markTextTarget(p)
|
||||
},
|
||||
target: p => textTarget(p),
|
||||
act: async p => {
|
||||
await textTarget(p).click()
|
||||
await expect(inspector(p).getByRole('button', { name: 'Effects' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
const head = inspector(p).getByRole('button', { name: 'Effects' })
|
||||
if ((await head.getAttribute('aria-expanded')) !== 'true') await head.click()
|
||||
await head.scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => inspector(p).locator('.st-section').filter({ has: p.getByRole('button', { name: 'Effects' }) }),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-background': {
|
||||
guide: guide('studio-background'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => inspector(p).locator('.st-section').filter({ hasText: 'Page background' }).first(),
|
||||
},
|
||||
{
|
||||
target: p => inspector(p).locator('.st-paint-tabs').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-layers': {
|
||||
guide: guide('studio-layers'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => railButton(p, 'Layers'),
|
||||
act: async p => {
|
||||
await studioSection(p, 'Layers')
|
||||
await expect(sidePanel(p).locator('.st-layer').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-layer').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 29 KiB |
@@ -3,7 +3,7 @@
|
||||
"@typescript-eslint/no-non-null-assertion": 384,
|
||||
"@typescript-eslint/no-misused-promises": 849,
|
||||
"no-console": 88,
|
||||
"complexity": 222,
|
||||
"complexity": 221,
|
||||
"@typescript-eslint/no-explicit-any": 146,
|
||||
"trek/no-swallowed-catch": 130,
|
||||
"max-params": 32,
|
||||
|
||||
@@ -72,7 +72,6 @@
|
||||
"components/Map/poiCategories.ts": 7,
|
||||
"components/Map/ratingBadge.ts": 2,
|
||||
"components/Map/reservationsMapbox.ts": 1,
|
||||
"components/Notifications/InAppNotificationItem.tsx": 1,
|
||||
"components/PDF/TripPDF.tsx": 53,
|
||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||
@@ -295,7 +294,6 @@
|
||||
"pages/FilesPage.tsx": 11,
|
||||
"pages/ForgotPasswordPage.tsx": 18,
|
||||
"pages/HelpPage.tsx": 21,
|
||||
"pages/InAppNotificationsPage.tsx": 2,
|
||||
"pages/JoinTripPage.tsx": 4,
|
||||
"pages/JourneyDetailPage.tsx": 53,
|
||||
"pages/JourneyPage.tsx": 64,
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type AiApiKeyConnectRequest,
|
||||
type AiAuthStartResponse,
|
||||
type AiChatgptFinishRequest,
|
||||
type AiClaudeConnectorResponse,
|
||||
type AiConnectionScope,
|
||||
type AiConnectionView,
|
||||
type AiInstanceConnectionRequest,
|
||||
@@ -233,7 +232,3 @@ export const aiAdminApi = {
|
||||
removeConnection: (): Promise<SuccessResponse> => apiClient.delete('/admin/ai/connection').then((r) => r.data),
|
||||
models: (): Promise<AiModelListResponse> => apiClient.get('/admin/ai/models', viaProvider).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const claudeConnectorApi = {
|
||||
get: (): Promise<AiClaudeConnectorResponse> => apiClient.get('/integrations/claude-connector').then((r) => r.data),
|
||||
};
|
||||
|
||||
@@ -102,6 +102,13 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
if (block.kind === 'h5') {
|
||||
return (
|
||||
<h5 key={idx} className="mb-1 mt-3 font-semibold text-content first:mt-0" style={fs(12.5, 'body')}>
|
||||
{block.text}
|
||||
</h5>
|
||||
);
|
||||
}
|
||||
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)}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ActionBarPrimitive, AuiIf, MessagePrimitive } from '@assistant-ui/react';
|
||||
import type { AssistantMessage as StoredMessage } from '@trek/shared';
|
||||
import { Check, Copy, FileText, LoaderCircle, Share2 } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { Fragment, useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
@@ -16,7 +16,7 @@ import AssistantShareDialog from './AssistantShareDialog';
|
||||
import AssistantSources from './AssistantSources';
|
||||
import AssistantToolSteps from './AssistantToolSteps';
|
||||
import AssistantWeatherStrip from './AssistantWeatherStrip';
|
||||
import { messageBlocks } from './assistantParts';
|
||||
import { type MessageBlock, messageBlocks, tailStart } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import AssistantCardBlock from './cards/AssistantCardBlock';
|
||||
import { useAssistantShare } from './useAssistantShare';
|
||||
@@ -108,6 +108,28 @@ function Question({ message }: { message: StoredMessage }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** One block of an answer, in the shape its kind takes. */
|
||||
function AnswerBlock({
|
||||
block,
|
||||
message,
|
||||
writing,
|
||||
streaming,
|
||||
}: {
|
||||
block: MessageBlock;
|
||||
message: StoredMessage;
|
||||
writing: boolean;
|
||||
streaming: boolean;
|
||||
}) {
|
||||
if (block.kind === 'text') return <AssistantMarkdown text={block.text} writing={streaming} />;
|
||||
if (block.kind === 'steps') return <AssistantToolSteps steps={block.steps} />;
|
||||
if (block.kind === 'places') return <AssistantPlaceStrip found={block.found} message={message} />;
|
||||
if (block.kind === 'weather') return <AssistantWeatherStrip days={block.days} />;
|
||||
if (block.kind === 'sources') return <AssistantSources sources={block.sources} />;
|
||||
if (block.kind === 'remembered') return <AssistantRemembered note={block.note} live={writing} />;
|
||||
if (block.kind === 'cards') return <AssistantCardBlock block={block} message={message} />;
|
||||
return <AssistantChangeCard items={block.items} />;
|
||||
}
|
||||
|
||||
function Answer({ message }: { message: StoredMessage }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId, variant } = useAssistantView();
|
||||
@@ -119,25 +141,16 @@ function Answer({ message }: { message: StoredMessage }) {
|
||||
const share = useAssistantShare(tripId, message, writing);
|
||||
const last = blocks[blocks.length - 1];
|
||||
const hasText = blocks.some((b) => b.kind === 'text');
|
||||
const tail = tailStart(blocks);
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col gap-2.5 py-2">
|
||||
{blocks.map((block) => {
|
||||
if (block.kind === 'text') {
|
||||
return <AssistantMarkdown key={block.key} text={block.text} writing={writing && block === last} />;
|
||||
}
|
||||
if (block.kind === 'steps') return <AssistantToolSteps key={block.key} steps={block.steps} />;
|
||||
if (block.kind === 'places') {
|
||||
return <AssistantPlaceStrip key={block.key} found={block.found} message={message} />;
|
||||
}
|
||||
if (block.kind === 'weather') return <AssistantWeatherStrip key={block.key} days={block.days} />;
|
||||
if (block.kind === 'sources') return <AssistantSources key={block.key} sources={block.sources} />;
|
||||
if (block.kind === 'remembered') {
|
||||
return <AssistantRemembered key={block.key} note={block.note} live={writing} />;
|
||||
}
|
||||
if (block.kind === 'cards') return <AssistantCardBlock key={block.key} block={block} message={message} />;
|
||||
return <AssistantChangeCard key={block.key} items={block.items} />;
|
||||
})}
|
||||
{blocks.map((block, i) => (
|
||||
<Fragment key={block.key}>
|
||||
{i === tail && i > 0 && <span data-assistant-tail aria-hidden="true" className="-mb-2.5 block h-0" />}
|
||||
<AnswerBlock block={block} message={message} writing={writing} streaming={writing && block === last} />
|
||||
</Fragment>
|
||||
))}
|
||||
{/* Nothing below says the answer goes on: no words being written, no step running. */}
|
||||
{writing && last?.kind !== 'text' && last?.kind !== 'steps' && (
|
||||
<p role="status" className="m-0 text-caption">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AuiIf, ThreadPrimitive } from '@assistant-ui/react';
|
||||
import { ArrowDown, FileUp, MapPin } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
@@ -15,13 +15,15 @@ import { useAssistantAttach } from './useAssistantAttach';
|
||||
import { useFollowSelectedDay } from './useAssistantFocus';
|
||||
import { joinDropZones, useAssistantPlaceDrop } from './useAssistantPlaceDrop';
|
||||
import { useAssistantSuggestions } from './useAssistantSuggestions';
|
||||
import { useRevealAnswerTail } from './useRevealAnswerTail';
|
||||
|
||||
/**
|
||||
* The chat itself: the messages, and under them the line that says what went
|
||||
* wrong, the privacy note and the composer, held at the bottom while the
|
||||
* messages scroll. A new question moves up to the top of the view, so its
|
||||
* answer reads from its first line instead of chasing the newest one. A
|
||||
* booking file or a place of the trip can be dropped anywhere on it.
|
||||
* answer reads from its first line instead of chasing the newest one, and once
|
||||
* the answer is written its end comes into view. A booking file or a place of
|
||||
* the trip can be dropped anywhere on it.
|
||||
*/
|
||||
export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -31,6 +33,8 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
||||
const byId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
|
||||
const { suggestions, canEdit } = useAssistantSuggestions(tripId);
|
||||
useFollowSelectedDay(tripId);
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
useRevealAnswerTail(viewport, isRunning);
|
||||
|
||||
const phone = variant === 'fullscreen';
|
||||
const attach = useAssistantAttach(tripId);
|
||||
@@ -52,7 +56,11 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ThreadPrimitive.Viewport turnAnchor="top" className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
|
||||
<ThreadPrimitive.Viewport
|
||||
ref={viewport}
|
||||
turnAnchor="top"
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4"
|
||||
>
|
||||
<AuiIf condition={(s) => s.thread.isEmpty}>
|
||||
<AssistantWelcome suggestions={suggestions} canEdit={canEdit} withSuggestions={!phone} />
|
||||
{mentionable && (
|
||||
|
||||
@@ -61,7 +61,7 @@ export interface ChangeItem {
|
||||
/** The answer cards of one group, side by side. */
|
||||
export type CardsBlock = { kind: 'cards'; key: string; group: string; entries: CardEntry[] };
|
||||
|
||||
type MessageBlock =
|
||||
export type MessageBlock =
|
||||
| { kind: 'text'; key: string; text: string }
|
||||
| { kind: 'steps'; key: string; steps: ToolStep[] }
|
||||
| { kind: 'change'; key: string; items: ChangeItem[] }
|
||||
@@ -464,3 +464,14 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
|
||||
packing: { labelKey: 'assistant.change.label.packing' },
|
||||
breaks: { labelKey: 'assistant.change.label.breaks' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Where an answer's closing words and changes begin: the block after its last
|
||||
* step, strip or card. That end is what the thread brings into view once the
|
||||
* answer is written.
|
||||
*/
|
||||
export function tailStart(blocks: MessageBlock[]): number {
|
||||
let i = blocks.length;
|
||||
while (i > 0 && (blocks[i - 1].kind === 'text' || blocks[i - 1].kind === 'change')) i -= 1;
|
||||
return i;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,11 @@ const STYLE_LOOK: Readonly<Record<BookDesignStyle, { labelKey: string }>> = {
|
||||
nordic: { labelKey: 'studio.theme.name.nordic' },
|
||||
alpine: { labelKey: 'studio.theme.name.alpine' },
|
||||
'india-stamp': { labelKey: 'studio.theme.name.indiaStamp' },
|
||||
riso: { labelKey: 'studio.theme.name.riso' },
|
||||
magazine: { labelKey: 'studio.theme.name.magazine' },
|
||||
'retro-travel': { labelKey: 'studio.theme.name.retroTravel' },
|
||||
midnight: { labelKey: 'studio.theme.name.midnight' },
|
||||
'field-notes': { labelKey: 'studio.theme.name.fieldNotes' },
|
||||
};
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* FE-AI-REVEAL-001 to FE-AI-REVEAL-007
|
||||
*
|
||||
* Once an answer is written, its closing words and changes come into view,
|
||||
* unless the user scrolled the thread themselves while it was written; and
|
||||
* where that end begins in an answer's blocks.
|
||||
*/
|
||||
import { useRef } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render } from '../../../tests/helpers/render';
|
||||
import { type MessageBlock, tailStart } from './assistantParts';
|
||||
import { tailScrollTop, useRevealAnswerTail } from './useRevealAnswerTail';
|
||||
|
||||
/** A viewport of 400px holding 1200px, its tail mark 900px down, scrolled to `scrollTop`. */
|
||||
function lay(viewport: HTMLElement, { tailAt = 900, scrollTop = 0, height = 1200 } = {}) {
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 400 });
|
||||
Object.defineProperty(viewport, 'scrollHeight', { configurable: true, value: height });
|
||||
viewport.scrollTop = scrollTop;
|
||||
viewport.getBoundingClientRect = () => ({ top: 100 }) as DOMRect;
|
||||
const tail = viewport.querySelector('[data-assistant-tail]') as HTMLElement | null;
|
||||
if (tail) tail.getBoundingClientRect = () => ({ top: 100 + tailAt - scrollTop }) as DOMRect;
|
||||
}
|
||||
|
||||
function Thread({ running }: { running: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useRevealAnswerTail(ref, running);
|
||||
return (
|
||||
<div ref={ref} data-testid="viewport" tabIndex={0}>
|
||||
<span data-assistant-tail />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const block = (kind: MessageBlock['kind']) => ({ kind, key: kind }) as MessageBlock;
|
||||
|
||||
describe('useRevealAnswerTail', () => {
|
||||
let scrollTo: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
scrollTo = vi.fn();
|
||||
HTMLElement.prototype.scrollTo = scrollTo as unknown as HTMLElement['scrollTo'];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 1;
|
||||
});
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('FE-AI-REVEAL-001: brings the end of the answer into view once it is written', () => {
|
||||
const view = render(<Thread running />);
|
||||
lay(view.getByTestId('viewport'), { height: 2000 });
|
||||
view.rerender(<Thread running={false} />);
|
||||
expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 888 }));
|
||||
});
|
||||
|
||||
it('FE-AI-REVEAL-002: leaves the thread where the user scrolled it while the answer was written', () => {
|
||||
const view = render(<Thread running />);
|
||||
const viewport = view.getByTestId('viewport');
|
||||
lay(viewport);
|
||||
fireEvent.wheel(viewport, { deltaY: 120 });
|
||||
view.rerender(<Thread running={false} />);
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-AI-REVEAL-003: does nothing on the first render or while nothing runs', () => {
|
||||
const view = render(<Thread running={false} />);
|
||||
lay(view.getByTestId('viewport'));
|
||||
view.rerender(<Thread running={false} />);
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-AI-REVEAL-004: a new answer reveals its end again after an earlier one was scrolled by hand', () => {
|
||||
const view = render(<Thread running />);
|
||||
const viewport = view.getByTestId('viewport');
|
||||
lay(viewport);
|
||||
fireEvent.wheel(viewport, { deltaY: 120 });
|
||||
view.rerender(<Thread running={false} />);
|
||||
view.rerender(<Thread running />);
|
||||
view.rerender(<Thread running={false} />);
|
||||
expect(scrollTo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailScrollTop', () => {
|
||||
it('FE-AI-REVEAL-005: stops at the bottom when the end is shorter than the view, and never scrolls up', () => {
|
||||
const viewport = document.createElement('div');
|
||||
viewport.innerHTML = '<span data-assistant-tail></span>';
|
||||
lay(viewport, { tailAt: 1100 });
|
||||
expect(tailScrollTop(viewport)).toBe(800);
|
||||
lay(viewport, { tailAt: 300, scrollTop: 500 });
|
||||
expect(tailScrollTop(viewport)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-REVEAL-006: without a mark there is nothing to reveal', () => {
|
||||
const viewport = document.createElement('div');
|
||||
lay(viewport);
|
||||
expect(tailScrollTop(viewport)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailStart', () => {
|
||||
it('FE-AI-REVEAL-007: the end begins after the last step, strip or card', () => {
|
||||
expect(tailStart([block('steps'), block('places'), block('text'), block('change')])).toBe(2);
|
||||
expect(tailStart([block('text'), block('places')])).toBe(2);
|
||||
expect(tailStart([block('text'), block('change')])).toBe(0);
|
||||
expect(tailStart([])).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { type RefObject, useEffect, useRef } from 'react';
|
||||
|
||||
import { prefersReducedMotion } from '../../utils/reducedMotion';
|
||||
|
||||
/** Room left above the tail, so its first line does not sit flush on the edge. */
|
||||
const TAIL_GAP = 12;
|
||||
/** What reading the thread by hand looks like while the answer is written. */
|
||||
const OWN_SCROLL = ['wheel', 'touchmove', 'keydown'] as const;
|
||||
|
||||
/**
|
||||
* Where to scroll so the end of the latest answer is in view: the start of its
|
||||
* closing text and proposed changes (the `data-assistant-tail` mark), or the
|
||||
* very bottom when that tail is shorter than the view. Null when it is in view
|
||||
* already, so the thread never moves up.
|
||||
*/
|
||||
export function tailScrollTop(viewport: HTMLElement): number | null {
|
||||
const marks = viewport.querySelectorAll('[data-assistant-tail]');
|
||||
const tail = marks[marks.length - 1];
|
||||
if (!tail) return null;
|
||||
const offset = tail.getBoundingClientRect().top - viewport.getBoundingClientRect().top + viewport.scrollTop;
|
||||
const target = Math.min(offset - TAIL_GAP, viewport.scrollHeight - viewport.clientHeight);
|
||||
return target > viewport.scrollTop + 1 ? target : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A new question moves to the top of the view, so its answer reads from the
|
||||
* first line. A long answer (steps, a strip of places found, cards) then ends
|
||||
* below the view, and its closing words and the changes to apply were never
|
||||
* seen. Once the answer is written, this brings that end into view, unless the
|
||||
* user scrolled the thread themselves while it was written.
|
||||
*/
|
||||
export function useRevealAnswerTail(viewport: RefObject<HTMLElement | null>, isRunning: boolean): void {
|
||||
const scrolledByHand = useRef(false);
|
||||
const wasRunning = useRef(isRunning);
|
||||
|
||||
useEffect(() => {
|
||||
const el = viewport.current;
|
||||
if (!el || !isRunning) return;
|
||||
scrolledByHand.current = false;
|
||||
const mark = () => {
|
||||
scrolledByHand.current = true;
|
||||
};
|
||||
for (const type of OWN_SCROLL) el.addEventListener(type, mark, { passive: true });
|
||||
return () => {
|
||||
for (const type of OWN_SCROLL) el.removeEventListener(type, mark);
|
||||
};
|
||||
}, [viewport, isRunning]);
|
||||
|
||||
useEffect(() => {
|
||||
const ended = wasRunning.current && !isRunning;
|
||||
wasRunning.current = isRunning;
|
||||
const el = viewport.current;
|
||||
if (!ended || !el || scrolledByHand.current) return;
|
||||
// A frame later, so the answer's last blocks have their height.
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const top = tailScrollTop(el);
|
||||
if (top !== null) el.scrollTo({ top, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [viewport, isRunning]);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Book, Palette, Map, Bell, Plug, CloudOff, User, Lock, ShieldCheck, Fingerprint, KeyRound,
|
||||
Route, Users, UserRound, Crown, PanelLeftClose, Undo2, Ticket, Train, Plane, ListChecks, Wallet, FolderOpen, MessagesSquare,
|
||||
ArrowUpDown, Backpack, BatteryCharging, BedDouble, BellRing, Bike, Blocks, CableCar, Car, CloudDownload, CopyPlus, FileDown, FilePen, FileSpreadsheet, FolderInput, Footprints, History, Info, Layers, MailCheck, MapPinned, Moon, Mountain, MountainSnow, MoveRight, NotebookPen, PackagePlus, Pin, Receipt, Rotate3d, RouteOff, Shuffle, Spline, TriangleAlert, Waves, Wind,
|
||||
PaintBucket, Sticker, Type,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import type { HelpIcon } from '../../help/types'
|
||||
@@ -13,6 +14,9 @@ import type { HelpIcon } from '../../help/types'
|
||||
/** The registry names icons; the components draw them. Unknown names fall back to the compass. */
|
||||
const ICONS: Record<HelpIcon, LucideIcon> = {
|
||||
plus: Plus,
|
||||
sticker: Sticker,
|
||||
type: Type,
|
||||
paintBucket: PaintBucket,
|
||||
pencil: Pencil,
|
||||
image: Image,
|
||||
copy: Copy,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-SHARE-001 to FE-JRN-SHARE-014
|
||||
// FE-JRN-SHARE-001 to FE-JRN-SHARE-015
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { http, HttpResponse, delay } from 'msw'
|
||||
@@ -245,4 +245,15 @@ describe('JourneyShareSection', () => {
|
||||
expect(plan).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(screen.getByRole('button', { name: /Recorded routes/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-JRN-SHARE-015: a filled accent button sets its words in the colour made for it, not in the accent itself', async () => {
|
||||
server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: existingLink() })))
|
||||
render(<JourneyShareSection journeyId={JOURNEY_ID} />)
|
||||
|
||||
const filled = [await screen.findByRole('button', { name: 'Timeline' }), screen.getByRole('button', { name: 'Copy' })]
|
||||
for (const button of filled) {
|
||||
expect(button.className).toContain('text-accent-text')
|
||||
expect(button.className).not.toContain('text-accent-on')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
|
||||
<span className="flex-1 text-caption text-content-muted truncate">{shareUrl}</span>
|
||||
<button type="button"
|
||||
onClick={copyLink}
|
||||
className="flex-shrink-0 px-3 py-1.5 rounded-full bg-accent text-accent-on text-caption font-semibold hover:bg-accent-hover transition-colors"
|
||||
className="flex-shrink-0 px-3 py-1.5 rounded-full bg-accent text-accent-text text-caption font-semibold hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
{copied ? t('journey.share.copied') : t('journey.share.copy')}
|
||||
</button>
|
||||
@@ -94,7 +94,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
|
||||
onClick={() => togglePerm(key)}
|
||||
className={`flex items-center gap-2.5 px-3 py-2 rounded-xl border text-body font-medium transition-all ${
|
||||
link[key]
|
||||
? 'border-accent bg-accent text-accent-on'
|
||||
? 'border-accent bg-accent text-accent-text'
|
||||
: 'border-edge text-content-muted hover:border-content-faint'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Bell, Trash2, CheckCheck } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx'
|
||||
import NotificationDayList from '../Notifications/NotificationDayList'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
|
||||
export default function InAppNotificationBell(): React.ReactElement {
|
||||
@@ -73,9 +73,9 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
position: 'fixed',
|
||||
top: 'var(--nav-h)',
|
||||
insetInlineEnd: 8,
|
||||
width: 380,
|
||||
width: 400,
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
|
||||
maxHeight: 'min(560px, calc(100vh - var(--nav-h) - 16px))',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -119,7 +119,7 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<div className="overflow-y-auto flex-1 py-1 border-t border-edge-secondary">
|
||||
<div className="overflow-y-auto flex-1 pb-1 border-t border-edge-secondary">
|
||||
{isLoading && notifications.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="w-5 h-5 border-2 rounded-full animate-spin border-edge border-t-content" />
|
||||
@@ -133,9 +133,7 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
<p className="text-caption text-content-muted">{t('notifications.emptyDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.slice(0, 10).map(n => (
|
||||
<InAppNotificationItem key={n.id} notification={n} onClose={() => setOpen(false)} />
|
||||
))
|
||||
<NotificationDayList notifications={notifications.slice(0, 10)} onClose={() => setOpen(false)} compact />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,29 +1,53 @@
|
||||
import React from 'react'
|
||||
import { User, Check, X, ArrowRight, Trash2, CheckCheck, Mail } from 'lucide-react'
|
||||
import { Check, X, ArrowRight, Trash2, CheckCheck, Mail } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { InAppNotification } from '../../store/inAppNotificationStore'
|
||||
import { compactTime, useNotificationItemActions } from './useNotificationItemActions'
|
||||
|
||||
function relativeTime(dateStr: string, locale: string): string {
|
||||
return compactTime(dateStr, locale === 'ar' ? 'الآن' : 'just now')
|
||||
}
|
||||
import { KIND_LOOK, createdAt, kindOf } from './notificationModel'
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: InAppNotification
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/** The round tool beside a notification, quiet until it is pointed at. */
|
||||
const TOOL = 'grid place-items-center w-7 h-7 rounded-lg transition-colors text-content-faint hover:text-content hover:bg-surface-card focus-visible:bg-surface-card focus-visible:text-content'
|
||||
/** The quiet tool beside a notification, forward only when it is pointed at. */
|
||||
const TOOL = 'grid place-items-center w-7 h-7 rounded-lg transition-colors text-content-faint hover:text-content hover:bg-surface-secondary focus-visible:bg-surface-secondary focus-visible:text-content'
|
||||
|
||||
/** An answer or a way on, as a pill under the words. */
|
||||
const PILL = 'inline-flex items-center gap-1.5 h-7 px-3 rounded-full text-caption font-medium transition-colors'
|
||||
/** An answer or a way on, as a button under the words. */
|
||||
const ACTION = 'inline-flex items-center gap-1.5 h-8 px-3 rounded-lg text-caption font-semibold transition-colors'
|
||||
|
||||
/** Under an hour it reads "5m"; after that the clock time, since the list is headed by its day. */
|
||||
function timeOf(value: string, locale: string, justNow: string): string {
|
||||
const date = createdAt(value)
|
||||
if (Date.now() - date.getTime() < 3_600_000) return compactTime(date.toISOString(), justNow)
|
||||
return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(date)
|
||||
}
|
||||
|
||||
/** The tile on the start side: the kind's symbol on its colour, and who sent it as a small face on its corner. */
|
||||
function KindTile({ notification }: { notification: InAppNotification }) {
|
||||
const { icon: Icon, tone } = KIND_LOOK[kindOf(notification.title_key)]
|
||||
const initial = notification.sender_username?.charAt(0).toUpperCase()
|
||||
return (
|
||||
<span className="relative block self-start flex-shrink-0">
|
||||
<span className={`grid place-items-center w-10 h-10 rounded-xl ${tone}`}>
|
||||
<Icon className="w-[18px] h-[18px]" aria-hidden />
|
||||
</span>
|
||||
{notification.sender_avatar ? (
|
||||
<img src={notification.sender_avatar} alt="" className="absolute -bottom-1 -end-1 w-5 h-5 rounded-full object-cover ring-2 ring-surface-card" />
|
||||
) : initial ? (
|
||||
<span aria-hidden className="absolute -bottom-1 -end-1 grid place-items-center w-5 h-5 rounded-full text-caption leading-none font-bold bg-accent text-accent-text ring-2 ring-surface-card">
|
||||
{initial}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One notification, as a card in the list: who sent it, what it says, and what
|
||||
* can be done with it. Unread ones carry a dot in the accent and a faint
|
||||
* ground; the tools to mark and delete come forward when the card is pointed
|
||||
* at or focused, so a list reads as messages rather than as rows of buttons.
|
||||
* One notification: a tile that says at a glance what it is about and who sent
|
||||
* it, the message itself, and what can be done with it. Unread ones are set in
|
||||
* bold with a dot in the accent; marking and deleting come forward when the row
|
||||
* is pointed at or focused, so a list reads as messages rather than as buttons.
|
||||
*/
|
||||
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -32,112 +56,96 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
|
||||
const titleText = t(notification.title_key, notification.title_params)
|
||||
const bodyText = t(notification.text_key, notification.text_params)
|
||||
const hasUnknownTitle = titleText === notification.title_key
|
||||
const hasUnknownBody = bodyText === notification.text_key
|
||||
const unread = !notification.is_read
|
||||
const answered = notification.response !== null
|
||||
const { positive_text_key: yesKey, negative_text_key: noKey, navigate_text_key: goKey } = notification
|
||||
const asks = notification.type === 'boolean' && yesKey && noKey
|
||||
const leads = notification.type === 'navigate' && goKey && notification.navigate_target
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative mx-2 my-1 rounded-xl px-3 py-3 transition-colors hover:bg-surface-hover focus-within:bg-surface-hover ${unread ? 'bg-surface-secondary' : ''}`}
|
||||
>
|
||||
{unread && <span aria-hidden className="absolute start-1 top-5 w-1.5 h-1.5 rounded-full bg-accent" />}
|
||||
<div className="group relative flex gap-3.5 px-4 py-3.5 transition-colors hover:bg-surface-hover focus-within:bg-surface-hover">
|
||||
<KindTile notification={notification} />
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
{/* Sender avatar */}
|
||||
<div className="flex-shrink-0">
|
||||
{notification.sender_avatar ? (
|
||||
<img src={notification.sender_avatar} alt="" className="w-9 h-9 rounded-xl object-cover" />
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-xl flex items-center justify-center text-body font-semibold text-accent bg-accent-subtle">
|
||||
{notification.sender_username
|
||||
? notification.sender_username.charAt(0).toUpperCase()
|
||||
: <User className="w-4 h-4" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className={`text-body leading-snug text-content ${unread ? 'font-semibold' : 'font-medium'}`}>
|
||||
{hasUnknownTitle ? notification.title_key : titleText}
|
||||
</p>
|
||||
<div className="relative flex-shrink-0 h-7 flex items-center">
|
||||
<span className="text-caption tabular-nums text-content-faint transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
|
||||
{relativeTime(notification.created_at, locale)}
|
||||
</span>
|
||||
<div className="absolute end-0 top-0 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
{notification.is_read ? (
|
||||
<button type="button" onClick={() => markUnread(notification.id)} title={t('notifications.markUnread')} aria-label={t('notifications.markUnread')} className={TOOL}>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => markRead(notification.id)} title={t('notifications.markRead')} aria-label={t('notifications.markRead')} className={TOOL}>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
title={t('notifications.delete')}
|
||||
aria-label={t('notifications.delete')}
|
||||
className={`${TOOL} hover:text-danger focus-visible:text-danger`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className={`text-body leading-snug text-content ${unread ? 'font-semibold' : 'font-medium'}`}>{titleText}</p>
|
||||
<div className="relative flex-shrink-0 h-6 flex items-center gap-2">
|
||||
<span className="text-caption tabular-nums text-content-faint transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
|
||||
{timeOf(notification.created_at, locale, t('common.justNow'))}
|
||||
</span>
|
||||
{unread && (
|
||||
<span aria-hidden className="w-2 h-2 rounded-full bg-accent transition-opacity group-hover:opacity-0 group-focus-within:opacity-0" />
|
||||
)}
|
||||
<div className="absolute end-0 top-1/2 -translate-y-1/2 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
{notification.is_read ? (
|
||||
<button type="button" onClick={() => markUnread(notification.id)} title={t('notifications.markUnread')} aria-label={t('notifications.markUnread')} className={TOOL}>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={() => markRead(notification.id)} title={t('notifications.markRead')} aria-label={t('notifications.markRead')} className={TOOL}>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
title={t('notifications.delete')}
|
||||
aria-label={t('notifications.delete')}
|
||||
className={`${TOOL} hover:text-danger focus-visible:text-danger`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-caption mt-0.5 leading-relaxed text-content-muted">
|
||||
{hasUnknownBody ? notification.text_key : bodyText}
|
||||
</p>
|
||||
<p className="text-body mt-0.5 leading-relaxed text-content-muted">{bodyText}</p>
|
||||
|
||||
{/* Boolean actions */}
|
||||
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
|
||||
<div className="flex gap-2 mt-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRespond('positive')}
|
||||
disabled={responding || answered}
|
||||
className={`${PILL} ${
|
||||
notification.response === 'positive'
|
||||
? 'bg-accent text-accent-text'
|
||||
: `bg-surface-card shadow-sm ${notification.response === 'negative' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
|
||||
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{t(notification.positive_text_key)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRespond('negative')}
|
||||
disabled={responding || answered}
|
||||
className={`${PILL} ${
|
||||
notification.response === 'negative'
|
||||
? 'bg-danger text-white'
|
||||
: `bg-surface-card shadow-sm ${notification.response === 'positive' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
|
||||
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
{t(notification.negative_text_key)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigate action */}
|
||||
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
|
||||
{asks && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNavigate}
|
||||
className={`${PILL} mt-2.5 bg-surface-card shadow-sm text-content hover:bg-accent hover:text-accent-text focus-visible:bg-accent focus-visible:text-accent-text`}
|
||||
onClick={() => handleRespond('positive')}
|
||||
disabled={responding || answered}
|
||||
className={`${ACTION} ${
|
||||
notification.response === 'positive'
|
||||
? 'bg-success-soft text-success'
|
||||
: answered
|
||||
? 'border border-edge text-content-faint'
|
||||
: 'bg-accent text-accent-text hover:bg-accent-hover'
|
||||
} disabled:cursor-default`}
|
||||
>
|
||||
{t(notification.navigate_text_key)}
|
||||
<ArrowRight className="w-3.5 h-3.5 rtl:-scale-x-100" />
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{t(yesKey)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRespond('negative')}
|
||||
disabled={responding || answered}
|
||||
className={`${ACTION} ${
|
||||
notification.response === 'negative'
|
||||
? 'bg-danger-soft text-danger'
|
||||
: answered
|
||||
? 'border border-edge text-content-faint'
|
||||
: 'border border-edge text-content hover:bg-surface-secondary'
|
||||
} disabled:cursor-default`}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
{t(noKey)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leads && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNavigate}
|
||||
className={`${ACTION} mt-3 border border-edge text-content hover:bg-accent hover:text-accent-text hover:border-transparent focus-visible:bg-accent focus-visible:text-accent-text`}
|
||||
>
|
||||
{t(goKey)}
|
||||
<ArrowRight className="w-3.5 h-3.5 rtl:-scale-x-100" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { InAppNotification } from '../../store/inAppNotificationStore';
|
||||
import InAppNotificationItem from './InAppNotificationItem';
|
||||
import { groupByDay } from './notificationModel';
|
||||
|
||||
interface NotificationDayListProps {
|
||||
notifications: InAppNotification[];
|
||||
/** Closes the bell's panel after a notification led somewhere. */
|
||||
onClose?: () => void;
|
||||
/** The bell's panel: headings stick to its top and the days need no cards of their own. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The notifications cut into days, each under its heading (Today, Yesterday,
|
||||
* a weekday, then the date). On the page every day is a card of its own; in
|
||||
* the bell's panel the headings stay at the top while the day scrolls by.
|
||||
*/
|
||||
export default function NotificationDayList({
|
||||
notifications,
|
||||
onClose,
|
||||
compact = false,
|
||||
}: NotificationDayListProps): React.ReactElement {
|
||||
const { locale } = useTranslation();
|
||||
const groups = useMemo(() => groupByDay(notifications, locale), [notifications, locale]);
|
||||
|
||||
return (
|
||||
<div className={compact ? '' : 'flex flex-col gap-6'}>
|
||||
{groups.map((group) => (
|
||||
<section key={group.key} aria-label={group.label}>
|
||||
<h2
|
||||
className={
|
||||
compact
|
||||
? 'sticky top-0 z-10 bg-surface-card px-4 pb-1.5 pt-3 text-caption font-semibold text-content-muted'
|
||||
: 'mb-2 px-1 text-caption font-semibold text-content-muted'
|
||||
}
|
||||
>
|
||||
{group.label}
|
||||
</h2>
|
||||
<div
|
||||
className={
|
||||
compact
|
||||
? 'divide-y divide-edge-secondary'
|
||||
: 'divide-y divide-edge-secondary overflow-hidden rounded-2xl border border-edge bg-surface-card'
|
||||
}
|
||||
>
|
||||
{group.items.map((n) => (
|
||||
<InAppNotificationItem key={n.id} notification={n} onClose={onClose} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* FE-NOTIF-MODEL-001 to FE-NOTIF-MODEL-007
|
||||
*
|
||||
* What a notification's tile shows, how the server's time is read, and how the
|
||||
* list is cut into days under headings in the reader's language.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import type { InAppNotification } from '../../store/inAppNotificationStore';
|
||||
import NotificationDayList from './NotificationDayList';
|
||||
import { KIND_LOOK, createdAt, dayLabel, groupByDay, kindOf } from './notificationModel';
|
||||
|
||||
const note = (id: number, created_at: string, title_key = 'notif.trip_invite.title'): InAppNotification => ({
|
||||
id,
|
||||
type: 'simple',
|
||||
scope: 'trip',
|
||||
target: 1,
|
||||
sender_id: null,
|
||||
sender_username: null,
|
||||
sender_avatar: null,
|
||||
recipient_id: 1,
|
||||
title_key,
|
||||
title_params: {},
|
||||
text_key: 'notifications.empty',
|
||||
text_params: {},
|
||||
positive_text_key: null,
|
||||
negative_text_key: null,
|
||||
response: null,
|
||||
navigate_text_key: null,
|
||||
navigate_target: null,
|
||||
is_read: false,
|
||||
created_at,
|
||||
});
|
||||
|
||||
const NOW = new Date(2026, 9, 24, 15, 0);
|
||||
const at = (days: number, hour = 10) => new Date(2026, 9, 24 - days, hour, 0);
|
||||
|
||||
describe('notificationModel', () => {
|
||||
it('FE-NOTIF-MODEL-001: every known title has its kind, anything else is the plain bell', () => {
|
||||
expect(kindOf('notif.collab_message.title')).toBe('message');
|
||||
expect(kindOf('notif.replica_failure.title')).toBe('alert');
|
||||
expect(kindOf('plugin.something.title')).toBe('other');
|
||||
for (const look of Object.values(KIND_LOOK)) expect(look.tone).toMatch(/^bg-/);
|
||||
});
|
||||
|
||||
it("FE-NOTIF-MODEL-002: the server's UTC text is read as UTC, an ISO string as it is", () => {
|
||||
expect(createdAt('2026-10-24 08:30:00').toISOString()).toBe('2026-10-24T08:30:00.000Z');
|
||||
expect(createdAt('2026-10-24T08:30:00.000Z').toISOString()).toBe('2026-10-24T08:30:00.000Z');
|
||||
});
|
||||
|
||||
it('FE-NOTIF-MODEL-003: today and yesterday by name, the week by weekday, older by date', () => {
|
||||
expect(dayLabel(at(0), 'en', NOW)).toBe('Today');
|
||||
expect(dayLabel(at(1), 'en', NOW)).toBe('Yesterday');
|
||||
expect(dayLabel(at(3), 'en', NOW)).toBe('Wednesday');
|
||||
expect(dayLabel(at(20), 'en', NOW)).toBe('October 4');
|
||||
expect(dayLabel(new Date(2025, 11, 31), 'en', NOW)).toBe('December 31, 2025');
|
||||
});
|
||||
|
||||
it('FE-NOTIF-MODEL-004: the headings follow the language', () => {
|
||||
expect(dayLabel(at(0), 'de', NOW)).toBe('Heute');
|
||||
expect(dayLabel(at(1), 'de', NOW)).toBe('Gestern');
|
||||
});
|
||||
|
||||
it('FE-NOTIF-MODEL-005: the list is cut into days in the order it came', () => {
|
||||
const groups = groupByDay(
|
||||
[note(1, at(0, 12).toISOString()), note(2, at(0, 9).toISOString()), note(3, at(2).toISOString())],
|
||||
'en',
|
||||
NOW
|
||||
);
|
||||
expect(groups.map((g) => [g.label, g.items.map((n) => n.id)])).toEqual([
|
||||
['Today', [1, 2]],
|
||||
['Thursday', [3]],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-NOTIF-MODEL-006: an empty list has no days', () => {
|
||||
expect(groupByDay([], 'en', NOW)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationDayList', () => {
|
||||
it('FE-NOTIF-MODEL-007: each day is a section under its heading', () => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 12);
|
||||
render(<NotificationDayList notifications={[note(1, now.toISOString()), note(2, yesterday.toISOString())]} />);
|
||||
expect(screen.getByRole('heading', { name: 'Today' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Yesterday' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('region')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
Backpack,
|
||||
Bell,
|
||||
BookmarkPlus,
|
||||
CalendarDays,
|
||||
Images,
|
||||
ListChecks,
|
||||
MessageCircle,
|
||||
Mountain,
|
||||
Plane,
|
||||
Sparkles,
|
||||
Ticket,
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { InAppNotification } from '../../store/inAppNotificationStore';
|
||||
|
||||
/** What a notification is about, read from its title key: it picks the symbol and colour of its tile. */
|
||||
export type NotificationKind =
|
||||
| 'trip'
|
||||
| 'message'
|
||||
| 'booking'
|
||||
| 'packing'
|
||||
| 'todo'
|
||||
| 'vacay'
|
||||
| 'collection'
|
||||
| 'photos'
|
||||
| 'tour'
|
||||
| 'update'
|
||||
| 'alert'
|
||||
| 'other';
|
||||
|
||||
const KIND_BY_TITLE: Record<string, NotificationKind> = {
|
||||
'notif.trip_invite.title': 'trip',
|
||||
'notif.trip_reminder.title': 'trip',
|
||||
'notif.collab_message.title': 'message',
|
||||
'notif.booking_change.title': 'booking',
|
||||
'notif.packing_tagged.title': 'packing',
|
||||
'notif.todo_due.title': 'todo',
|
||||
'notif.vacay_invite.title': 'vacay',
|
||||
'notif.vacay_share.title': 'vacay',
|
||||
'notif.collection_invite.title': 'collection',
|
||||
'notif.photos_shared.title': 'photos',
|
||||
'notif.wanderer_source_hint.title': 'tour',
|
||||
'notif.paddle_region_requested.title': 'tour',
|
||||
'notif.version_available.title': 'update',
|
||||
'notifications.versionAvailable.title': 'update',
|
||||
'notif.replica_failure.title': 'alert',
|
||||
'notifications.synologySessionCleared.title': 'alert',
|
||||
};
|
||||
|
||||
export function kindOf(titleKey: string): NotificationKind {
|
||||
return KIND_BY_TITLE[titleKey] ?? 'other';
|
||||
}
|
||||
|
||||
/** The symbol of each kind and the tinted tile it sits on. */
|
||||
export const KIND_LOOK: Record<NotificationKind, { icon: LucideIcon; tone: string }> = {
|
||||
trip: { icon: Plane, tone: 'bg-info-soft text-info' },
|
||||
message: { icon: MessageCircle, tone: 'bg-success-soft text-success' },
|
||||
booking: { icon: Ticket, tone: 'bg-warning-soft text-warning' },
|
||||
packing: { icon: Backpack, tone: 'bg-warning-soft text-warning' },
|
||||
todo: { icon: ListChecks, tone: 'bg-success-soft text-success' },
|
||||
vacay: { icon: CalendarDays, tone: 'bg-info-soft text-info' },
|
||||
collection: { icon: BookmarkPlus, tone: 'bg-accent-subtle text-accent' },
|
||||
photos: { icon: Images, tone: 'bg-warning-soft text-warning' },
|
||||
tour: { icon: Mountain, tone: 'bg-success-soft text-success' },
|
||||
update: { icon: Sparkles, tone: 'bg-accent-subtle text-accent' },
|
||||
alert: { icon: TriangleAlert, tone: 'bg-danger-soft text-danger' },
|
||||
other: { icon: Bell, tone: 'bg-surface-secondary text-content-secondary' },
|
||||
};
|
||||
|
||||
/**
|
||||
* When a notification was made. The server writes `YYYY-MM-DD HH:MM:SS` in UTC,
|
||||
* which a bare `new Date()` would read as local time.
|
||||
*/
|
||||
export function createdAt(value: string): Date {
|
||||
return new Date(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(value) ? `${value.replace(' ', 'T')}Z` : value);
|
||||
}
|
||||
|
||||
const dayStart = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
|
||||
/**
|
||||
* The heading of the day a notification belongs to, in the reader's language:
|
||||
* today and yesterday by name, the rest of the week by weekday, anything older
|
||||
* by its date (with the year when it is not this one).
|
||||
*/
|
||||
export function dayLabel(date: Date, locale: string, now: Date = new Date()): string {
|
||||
const days = Math.round((dayStart(now) - dayStart(date)) / 86_400_000);
|
||||
if (days <= 1) {
|
||||
const word = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(-Math.max(days, 0), 'day');
|
||||
return word.charAt(0).toLocaleUpperCase(locale) + word.slice(1);
|
||||
}
|
||||
if (days < 7) return new Intl.DateTimeFormat(locale, { weekday: 'long' }).format(date);
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }),
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export interface NotificationGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
items: InAppNotification[];
|
||||
}
|
||||
|
||||
/** The list cut into days, in the order it came (newest first). */
|
||||
export function groupByDay(list: InAppNotification[], locale: string, now: Date = new Date()): NotificationGroup[] {
|
||||
const groups: NotificationGroup[] = [];
|
||||
for (const n of list) {
|
||||
const date = createdAt(n.created_at);
|
||||
const key = String(dayStart(date));
|
||||
const current = groups[groups.length - 1];
|
||||
if (current?.key === key) current.items.push(n);
|
||||
else groups.push({ key, label: dayLabel(date, locale, now), items: [n] });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
@@ -33,12 +33,6 @@ function mcpEndpointRow() {
|
||||
|
||||
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const LAN_CONNECTOR = {
|
||||
mcpUrl: 'http://192.168.1.20:3000/mcp',
|
||||
deepLink: 'https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=TREK&connectorUrl=x',
|
||||
qrSvg: '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
|
||||
};
|
||||
|
||||
function enableAddons(ids: string[]) {
|
||||
seedStore(useAddonStore, {
|
||||
addons: ids.map(id => ({ id, name: id, type: 'integration', icon: '', enabled: true })),
|
||||
@@ -71,7 +65,6 @@ beforeEach(() => {
|
||||
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
|
||||
http.get('/api/oauth/clients', () => HttpResponse.json({ clients: [] })),
|
||||
http.get('/api/oauth/sessions', () => HttpResponse.json({ sessions: [] })),
|
||||
http.get('/api/integrations/claude-connector', () => HttpResponse.json(LAN_CONNECTOR)),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1026,25 +1019,4 @@ describe('IntegrationsTab – failure toasts', () => {
|
||||
render(<IntegrationsTab />);
|
||||
expect(screen.queryByText('What would you like to chat with?')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-049: the Claude card explains why a LAN install cannot be added', async () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
expect(await screen.findByText('Claude can only reach TREK when it is available over https on the internet.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: /Open in Claude/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-050: the Claude card links a public install into Claude', async () => {
|
||||
server.use(
|
||||
http.get('/api/integrations/claude-connector', () =>
|
||||
HttpResponse.json({ ...LAN_CONNECTOR, mcpUrl: 'https://trip.example.org/mcp' }),
|
||||
),
|
||||
);
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
const link = await screen.findByRole('link', { name: /Open in Claude/ });
|
||||
expect(link).toHaveAttribute('href', LAN_CONNECTOR.deepLink);
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(screen.getByText('https://trip.example.org/mcp')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import AirTrailConnectionSection from './AirTrailConnectionSection'
|
||||
import DawarichConnectionSection from './DawarichConnectionSection'
|
||||
import LlmConnectionSection from './LlmConnectionSection'
|
||||
import AiConnectionSection from './ai/AiConnectionSection'
|
||||
import ClaudeConnectorCard from './ai/ClaudeConnectorCard'
|
||||
import ApiKeysSection from './ApiKeysSection'
|
||||
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
@@ -45,7 +44,6 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
one should not have to read past a section about AI assistants. */}
|
||||
<ApiKeysSection />
|
||||
{/* TREK inside Claude rides on the MCP server, so it follows that addon. */}
|
||||
{S.mcpEnabled && <ClaudeConnectorCard />}
|
||||
{S.mcpEnabled && <IntegrationsMcpSection S={S} />}
|
||||
<McpTokenModals S={S} />
|
||||
<OAuthClientModals S={S} />
|
||||
|
||||
@@ -3,8 +3,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { resetAllStores } from '../../../../tests/helpers/store';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
@@ -173,17 +172,11 @@ describe('AiConnectPanel', () => {
|
||||
expect(screen.getByRole('button', { name: /Connect OpenRouter/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-CONNECT-PANEL-008: "Using Claude?" shows with the MCP addon and the sharing details fold out', async () => {
|
||||
it('FE-COMP-AI-CONNECT-PANEL-008: the sharing details fold out', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = render(<AiConnectPanel scope="user" onConnected={vi.fn()} />);
|
||||
expect(screen.queryByText('Using Claude? Connect in Claude')).not.toBeInTheDocument();
|
||||
render(<AiConnectPanel scope="user" onConnected={vi.fn()} />);
|
||||
await user.click(screen.getByRole('button', { name: /What gets shared\?/ }));
|
||||
expect(screen.getByText('Not your password, your files or your other trips.')).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
seedStore(useAddonStore, { addons: [{ id: 'mcp', name: 'MCP', type: 'integration', icon: '', enabled: true }] });
|
||||
render(<AiConnectPanel scope="user" onConnected={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: 'Using Claude? Connect in Claude' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-CONNECT-PANEL-009: the admin signing in with ChatGPT is told the plan is shared, and the sign-in is for everyone', async () => {
|
||||
|
||||
@@ -2,14 +2,13 @@ import type { AiApiKeyKind, AiConnectionScope, AiParsingPrefill } from '@trek/sh
|
||||
import { ChevronRight, KeyRound, Loader2, Server, ShieldCheck } from 'lucide-react';
|
||||
import React, { useId, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { AI_PROVIDERS, API_KEY_KINDS, providerLabel } from '../../Assistant/providerCatalog';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { EditorField, GRID_2, INPUT } from '../../shared/dialogParts';
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingsHint, StatusPill } from '../settingsKit';
|
||||
import ChatGptPasteStep, { ConnectErrorNote } from './ChatGptPasteStep';
|
||||
import { CLAUDE_CONNECTOR_ANCHOR, type AiConnectMode } from './aiConnectModel';
|
||||
import { type AiConnectMode } from './aiConnectModel';
|
||||
import { useChatgptConnect, useCustomConnect, useKeyConnect, useOpenrouterStart } from './useAiConnect';
|
||||
|
||||
interface AiConnectPanelProps {
|
||||
@@ -399,7 +398,6 @@ export default function AiConnectPanel({ scope, onConnected, initialMode, prefil
|
||||
const chatgpt = useChatgptConnect({ active: mode === 'chatgpt', onConnected, scope });
|
||||
const keyConnect = useKeyConnect({ scope, prefill, onConnected });
|
||||
const custom = useCustomConnect({ prefill, onConnected });
|
||||
const mcpEnabled = useAddonStore((s) => s.isEnabled('mcp'));
|
||||
|
||||
// The privacy line names whoever would receive the messages, once that is known.
|
||||
const receivers: Record<AiConnectMode, string> = {
|
||||
@@ -450,18 +448,6 @@ export default function AiConnectPanel({ scope, onConnected, initialMode, prefil
|
||||
{mode === 'custom' && <CustomForm custom={custom} />}
|
||||
|
||||
<PrivacyNote provider={mode ? receivers[mode] : ''} />
|
||||
{scope === 'user' && mcpEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
document.getElementById(CLAUDE_CONNECTOR_ANCHOR)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
className="self-start font-medium text-content-muted underline underline-offset-2 hover:text-content"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{t('assistant.connect.claudeLink')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
// FE-COMP-CLAUDE-CONNECTOR-001 to FE-COMP-CLAUDE-CONNECTOR-003: adding TREK to Claude as a connector.
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
|
||||
const repo = vi.hoisted(() => ({ claudeConnector: vi.fn() }));
|
||||
const clipboard = vi.hoisted(() => ({ copyText: vi.fn() }));
|
||||
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
|
||||
vi.mock('../../../utils/clipboard', () => clipboard);
|
||||
|
||||
const { default: ClaudeConnectorCard } = await import('./ClaudeConnectorCard');
|
||||
|
||||
const CONNECTOR = {
|
||||
mcpUrl: 'https://trip.example.org/mcp',
|
||||
deepLink:
|
||||
'https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=TREK&connectorUrl=https%3A%2F%2Ftrip.example.org%2Fmcp',
|
||||
qrSvg: '<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1"/></svg>',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clipboard.copyText.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe('ClaudeConnectorCard', () => {
|
||||
it('FE-COMP-CLAUDE-CONNECTOR-001: links a public install into Claude, with a QR code that is an image, not markup', async () => {
|
||||
repo.claudeConnector.mockResolvedValue(CONNECTOR);
|
||||
const { container } = render(<ClaudeConnectorCard />);
|
||||
const link = await screen.findByRole('link', { name: /Open in Claude/ });
|
||||
expect(link).toHaveAttribute('href', CONNECTOR.deepLink);
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
const qr = screen.getByAltText('QR code to add TREK in Claude');
|
||||
expect(qr.getAttribute('src')).toMatch(/^data:image\/svg\+xml;charset=utf-8,/);
|
||||
// The QR code's own markup never lands in the page; icons may draw rects of their own.
|
||||
expect(container.querySelector('rect[width="1"][height="1"]')).toBeNull();
|
||||
expect(document.getElementById('claude-connector')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-CLAUDE-CONNECTOR-002: copies the address and shows the tick for a moment', async () => {
|
||||
const user = userEvent.setup();
|
||||
repo.claudeConnector.mockResolvedValue(CONNECTOR);
|
||||
render(<ClaudeConnectorCard />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Copy address' }));
|
||||
expect(clipboard.copyText).toHaveBeenCalledWith(CONNECTOR.mcpUrl);
|
||||
await waitFor(() => expect(document.querySelector('.text-success')).not.toBeNull());
|
||||
});
|
||||
|
||||
it('FE-COMP-CLAUDE-CONNECTOR-003: a plain-http or LAN install gets the reason instead of a link, and a failed read only the description', async () => {
|
||||
repo.claudeConnector.mockResolvedValueOnce({ ...CONNECTOR, mcpUrl: 'http://192.168.1.20:3000/mcp' });
|
||||
const { unmount } = render(<ClaudeConnectorCard />);
|
||||
expect(
|
||||
await screen.findByText('Claude can only reach TREK when it is available over https on the internet.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
repo.claudeConnector.mockRejectedValueOnce(new Error('404'));
|
||||
render(<ClaudeConnectorCard />);
|
||||
expect(screen.getByText('Use TREK right inside Claude: add it as a connector, sign in, done.')).toBeInTheDocument();
|
||||
await waitFor(() => expect(repo.claudeConnector).toHaveBeenCalledTimes(2));
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Check, Copy, ExternalLink, MessagesSquare } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingsCard, SettingsHint } from '../settingsKit';
|
||||
import { CLAUDE_CONNECTOR_ANCHOR } from './aiConnectModel';
|
||||
import { useClaudeConnector } from './useClaudeConnector';
|
||||
|
||||
type ClaudeConnector = ReturnType<typeof useClaudeConnector>;
|
||||
|
||||
/** The way in: the button that adds TREK on claude.ai, the address to copy, and the QR code for a phone. */
|
||||
function ConnectorLinks({
|
||||
connector,
|
||||
qrSrc,
|
||||
copied,
|
||||
copyUrl,
|
||||
}: Omit<ClaudeConnector, 'connector' | 'reachable'> & { connector: NonNullable<ClaudeConnector['connector']> }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-5">
|
||||
<div className="flex min-w-0 flex-1 basis-64 flex-col gap-3">
|
||||
<div className="flex">
|
||||
<a
|
||||
href={connector.deepLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
<ExternalLink size={14} strokeWidth={2.2} /> {t('assistant.claude.open')}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
dir="ltr"
|
||||
className="block min-w-0 flex-1 truncate rounded-[10px] border border-edge-faint bg-surface-tertiary px-3 py-2 font-mono text-content"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{connector.mcpUrl}
|
||||
</code>
|
||||
<Tooltip label={copied ? t('common.copied') : t('assistant.claude.copyUrl')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyUrl()}
|
||||
aria-label={t('assistant.claude.copyUrl')}
|
||||
className={SETTINGS_ICON_BUTTON}
|
||||
>
|
||||
{copied ? (
|
||||
<Check size={15} strokeWidth={2.2} className="text-success" />
|
||||
) : (
|
||||
<Copy size={15} strokeWidth={2} />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<figure className="m-0 flex flex-none flex-col items-center gap-1.5 max-sm:hidden">
|
||||
<img
|
||||
src={qrSrc}
|
||||
alt={t('assistant.claude.qrAlt')}
|
||||
width={112}
|
||||
height={112}
|
||||
className="rounded-[10px] shadow-sm ring-1 ring-edge-faint"
|
||||
/>
|
||||
<figcaption className="text-content-faint" style={fs(11)}>
|
||||
{t('assistant.claude.scan')}
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Connect with Claude": TREK as a connector inside Claude, over the MCP
|
||||
* server and its OAuth. One button adds it on claude.ai; the QR code does the
|
||||
* same from a phone, so it only shows where a phone is not already in hand.
|
||||
*/
|
||||
export default function ClaudeConnectorCard(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { connector, reachable, ...links } = useClaudeConnector();
|
||||
|
||||
// Null keeps the card to its band until the address is known.
|
||||
let body: React.ReactNode = null;
|
||||
if (connector && reachable) body = <ConnectorLinks connector={connector} {...links} />;
|
||||
else if (connector) body = <SettingsHint>{t('assistant.claude.notPublic')}</SettingsHint>;
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
id={CLAUDE_CONNECTOR_ANCHOR}
|
||||
icon={MessagesSquare}
|
||||
title={t('assistant.claude.title')}
|
||||
hint={t('assistant.claude.description')}
|
||||
>
|
||||
{body}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,6 @@ import { OnlineOnlyError } from '../../../repo/onlineOnly';
|
||||
/** How a connection is being set up: a ChatGPT sign-in, a pasted key, or (admin only) an own server. */
|
||||
export type AiConnectMode = 'chatgpt' | 'key' | 'custom';
|
||||
|
||||
/** Where the Claude connector card sits, for the "Using Claude?" line to jump to. */
|
||||
export const CLAUDE_CONNECTOR_ANCHOR = 'claude-connector';
|
||||
|
||||
/** The `code` of a refused AI request (`{ error, code }`), or null when the failure carries none. */
|
||||
export function refusalCode(err: unknown): string | null {
|
||||
if (err instanceof OnlineOnlyError) return 'offline';
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { AiClaudeConnectorResponse } from '@trek/shared';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { aiConnectionsRepo } from '../../../repo/aiConnectionsRepo';
|
||||
import { copyText } from '../../../utils/clipboard';
|
||||
import { isPublicHttpsUrl, svgDataUrl } from './aiConnectModel';
|
||||
|
||||
/**
|
||||
* Adding TREK to Claude as a connector (no AI connection of TREK's own
|
||||
* involved): the MCP address, the claude.ai link that adds it, and a QR code
|
||||
* for doing it from a phone. Claude reaches TREK from the internet, so on a
|
||||
* LAN or plain-http install the card explains that instead of offering a link
|
||||
* that would fail after the user did everything right.
|
||||
*/
|
||||
export function useClaudeConnector() {
|
||||
const [connector, setConnector] = useState<AiClaudeConnectorResponse | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
aiConnectionsRepo.claudeConnector().then(
|
||||
(next) => {
|
||||
if (!cancelled) setConnector(next);
|
||||
},
|
||||
() => {
|
||||
// Without an answer the card keeps to its description; nothing to link to.
|
||||
if (!cancelled) setConnector(null);
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (copiedTimer.current) clearTimeout(copiedTimer.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const copyUrl = async () => {
|
||||
if (!connector || !(await copyText(connector.mcpUrl))) return;
|
||||
setCopied(true);
|
||||
if (copiedTimer.current) clearTimeout(copiedTimer.current);
|
||||
copiedTimer.current = setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return {
|
||||
connector,
|
||||
reachable: connector ? isPublicHttpsUrl(connector.mcpUrl) : false,
|
||||
qrSrc: connector ? svgDataUrl(connector.qrSvg) : '',
|
||||
copied,
|
||||
copyUrl,
|
||||
};
|
||||
}
|
||||
@@ -90,7 +90,7 @@ describe('the Templates panel', () => {
|
||||
it('narrows the set to one archetype from the style chips', () => {
|
||||
panel();
|
||||
const chips = screen.getByRole('radiogroup', { name: 'studio.templates.style' });
|
||||
expect(chips.querySelectorAll('[role="radio"]')).toHaveLength(8);
|
||||
expect(chips.querySelectorAll('[role="radio"]')).toHaveLength(13);
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'studio.templates.archetype.postcard' }));
|
||||
expect(
|
||||
screen.getByRole('radio', { name: 'studio.templates.archetype.postcard' }).getAttribute('aria-checked')
|
||||
|
||||
@@ -22,7 +22,7 @@ import { ThemeSheet } from './ThemeSheet';
|
||||
import { useStudioTheme } from './useStudioTheme';
|
||||
|
||||
/**
|
||||
* The Theme section of the rail: twelve built-in looks, the palette, the
|
||||
* The Theme section of the rail: seventeen built-in looks, the palette, the
|
||||
* typefaces and the motifs of the one being put together, and applying it to
|
||||
* the book or to the page that is open.
|
||||
*
|
||||
|
||||
@@ -65,16 +65,22 @@ export const { context: studioContext, guides: studioGuides } = defineScreen({
|
||||
id: 'journey-studio',
|
||||
parent: 'journey',
|
||||
icon: 'book',
|
||||
bullets: 6,
|
||||
bullets: 8,
|
||||
docs: [STUDIO],
|
||||
guides: [
|
||||
['studio-auto-layout', 'sparkles', 'guide', 3, 2, STUDIO, ['studio-pages', 'studio-layouts'], true],
|
||||
['studio-pages', 'book', 'guide', 3, 2, STUDIO, ['studio-layouts', 'studio-spread-file'], true],
|
||||
['studio-layouts', 'layout', 'quick', 2, 1, STUDIO, ['studio-auto-layout', 'studio-content'], true],
|
||||
['studio-layouts', 'layout', 'quick', 2, 1, STUDIO, ['studio-my-layouts', 'studio-auto-layout', 'studio-content'], true],
|
||||
['studio-my-layouts', 'bookmark', 'quick', 2, 1, STUDIO, ['studio-layouts', 'studio-spread-file'], true],
|
||||
['studio-content', 'image', 'guide', 3, 2, STUDIO, ['studio-properties', 'studio-elements'], true],
|
||||
['studio-elements', 'plus', 'quick', 2, 1, STUDIO, ['studio-travel', 'studio-properties']],
|
||||
['studio-travel', 'globe', 'quick', 2, 1, STUDIO, ['studio-elements']],
|
||||
['studio-properties', 'panelRight', 'guide', 3, 2, STUDIO, ['studio-content', 'studio-layouts']],
|
||||
['studio-theme', 'palette', 'guide', 3, 2, STUDIO, ['studio-background', 'studio-text-effects'], true],
|
||||
['studio-elements', 'plus', 'quick', 2, 1, STUDIO, ['studio-stickers', 'studio-travel', 'studio-properties']],
|
||||
['studio-stickers', 'sticker', 'quick', 2, 1, STUDIO, ['studio-elements', 'studio-layers'], true],
|
||||
['studio-travel', 'globe', 'guide', 3, 2, STUDIO, ['studio-elements', 'studio-theme'], true],
|
||||
['studio-text-effects', 'type', 'quick', 2, 1, STUDIO, ['studio-theme', 'studio-properties'], true],
|
||||
['studio-background', 'paintBucket', 'quick', 2, 1, STUDIO, ['studio-theme', 'studio-layouts'], true],
|
||||
['studio-layers', 'layers', 'quick', 2, 2, STUDIO, ['studio-properties', 'studio-stickers'], true],
|
||||
['studio-properties', 'panelRight', 'guide', 3, 2, STUDIO, ['studio-content', 'studio-layers']],
|
||||
['studio-format', 'filter', 'quick', 2, 2, STUDIO, ['studio-export']],
|
||||
['studio-export', 'upload', 'guide', 3, 2, STUDIO, ['studio-format', 'studio-spread-file']],
|
||||
['studio-spread-file', 'fileInput', 'quick', 2, 1, STUDIO, ['studio-export', 'studio-pages']],
|
||||
|
||||
@@ -36,6 +36,9 @@ export interface HelpGuideMedia {
|
||||
/** Icon names the help components know how to draw (see components/Help/helpIcons.ts). */
|
||||
export type HelpIcon =
|
||||
| 'plus'
|
||||
| 'sticker'
|
||||
| 'type'
|
||||
| 'paintBucket'
|
||||
| 'pencil'
|
||||
| 'image'
|
||||
| 'copy'
|
||||
|
||||
@@ -83,6 +83,13 @@ export default function MAdminGitHubPanel({ isPrerelease = false }: { isPrerelea
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
if (block.kind === 'h5') {
|
||||
return (
|
||||
<h5 key={idx} className="text-caption font-semibold mt-3 mb-1 text-m-ink">
|
||||
{block.text}
|
||||
</h5>
|
||||
);
|
||||
}
|
||||
if (block.kind === 'h4') {
|
||||
return (
|
||||
<h4 key={idx} className="text-xs font-semibold mt-3 mb-1 text-m-ink">
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { Check, Copy, ExternalLink, MessagesSquare } from 'lucide-react'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import MPhotoProvidersSection from './MPhotoProvidersSection'
|
||||
import MAirTrailConnectionSection from './MAirTrailConnectionSection'
|
||||
import MDawarichConnectionSection from './MDawarichConnectionSection'
|
||||
import MLlmConnectionSection from './MLlmConnectionSection'
|
||||
import MAiConnectionSection from './MAiConnectionSection'
|
||||
import MSettingsMcp from './MSettingsMcp'
|
||||
import { MSetButton, MSetCard, MSetHint } from './MSettingsUi'
|
||||
import { useIntegrationGates } from '../../../components/Settings/useIntegrationGates'
|
||||
import { useClaudeConnector } from '../../../components/Settings/ai/useClaudeConnector'
|
||||
import { CLAUDE_CONNECTOR_ANCHOR } from '../../../components/Settings/ai/aiConnectModel'
|
||||
|
||||
/**
|
||||
* "Integrations" section. The photo-provider / AirTrail / LLM connection forms
|
||||
@@ -33,35 +28,7 @@ export default function MSettingsIntegrations() {
|
||||
a managed install. The per-user fallback exists for people who supply their
|
||||
own key, and there nobody does. */}
|
||||
{llmEnabled && ownParsing && !managed && <MLlmConnectionSection />}
|
||||
{mcpEnabled && <MClaudeConnectorCard />}
|
||||
{mcpEnabled && <MSettingsMcp />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Connect with Claude" on the phone: the button and the address only. The QR
|
||||
* code is for reaching a phone from a desktop, and this is the phone.
|
||||
*/
|
||||
function MClaudeConnectorCard() {
|
||||
const { t } = useTranslation()
|
||||
const { connector, reachable, copied, copyUrl } = useClaudeConnector()
|
||||
return (
|
||||
<div id={CLAUDE_CONNECTOR_ANCHOR}>
|
||||
<MSetCard title={t('assistant.claude.title')} icon={MessagesSquare} className="mt-3">
|
||||
<MSetHint className="mb-3">{t('assistant.claude.description')}</MSetHint>
|
||||
{connector && !reachable && <MSetHint>{t('assistant.claude.notPublic')}</MSetHint>}
|
||||
{connector && reachable && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<MSetButton onClick={() => window.open(connector.deepLink, '_blank', 'noopener,noreferrer')}>
|
||||
<ExternalLink size={14} /> {t('assistant.claude.open')}
|
||||
</MSetButton>
|
||||
<MSetButton variant="ghost" onClick={() => void copyUrl()}>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />} {copied ? t('common.copied') : t('assistant.claude.copyUrl')}
|
||||
</MSetButton>
|
||||
</div>
|
||||
)}
|
||||
</MSetCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import HelpAnchor from '../components/Help/HelpAnchor'
|
||||
import EmptyState from '../components/shared/EmptyState'
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import { Spinner } from '../components/shared/Spinner'
|
||||
import InAppNotificationItem from '../components/Notifications/InAppNotificationItem.tsx'
|
||||
import NotificationDayList from '../components/Notifications/NotificationDayList'
|
||||
import { useInAppNotifications } from './inAppNotifications/useInAppNotifications'
|
||||
|
||||
export default function InAppNotificationsPage(): React.ReactElement {
|
||||
@@ -88,27 +88,25 @@ function InAppNotificationsPageDesktop(): React.ReactElement {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<div className="rounded-2xl border overflow-hidden border-edge bg-surface-card py-1">
|
||||
{isLoading && displayed.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Spinner className="w-6 h-6 border-2 border-slate-200 border-t-current" />
|
||||
</div>
|
||||
) : displayed.length === 0 ? (
|
||||
{/* The list, one card per day. */}
|
||||
{isLoading && displayed.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Spinner className="w-6 h-6 border-2 border-edge border-t-current" />
|
||||
</div>
|
||||
) : displayed.length === 0 ? (
|
||||
<div className="rounded-2xl border border-edge bg-surface-card">
|
||||
<EmptyState scene="notifications" title={t('notifications.empty')} />
|
||||
) : (
|
||||
displayed.map(n => (
|
||||
<InAppNotificationItem key={n.id} notification={n} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<NotificationDayList notifications={displayed} />
|
||||
)}
|
||||
|
||||
{/* Infinite scroll trigger */}
|
||||
{hasMore && (
|
||||
<div ref={loaderRef} className="flex items-center justify-center py-4">
|
||||
{isLoading && <Spinner className="w-5 h-5 border-2 border-slate-200 border-t-current" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Infinite scroll trigger */}
|
||||
{hasMore && (
|
||||
<div ref={loaderRef} className="flex items-center justify-center py-4">
|
||||
{isLoading && <Spinner className="w-5 h-5 border-2 border-edge border-t-current" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
isOpen={confirmingDeleteAll}
|
||||
|
||||
@@ -126,6 +126,5 @@ describe('aiConnectionsRepo', () => {
|
||||
await expect(aiConnectionsRepo.disconnect()).rejects.toBeInstanceOf(OnlineOnlyError);
|
||||
await expect(aiConnectionsRepo.chatgptStart()).rejects.toBeInstanceOf(OnlineOnlyError);
|
||||
await expect(aiConnectionsRepo.adminSettings()).rejects.toBeInstanceOf(OnlineOnlyError);
|
||||
await expect(aiConnectionsRepo.claudeConnector()).rejects.toBeInstanceOf(OnlineOnlyError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
aiAdminConnectionResponseSchema,
|
||||
aiAdminSettingsSchema,
|
||||
aiAuthStartResponseSchema,
|
||||
aiClaudeConnectorResponseSchema,
|
||||
aiConnectionViewSchema,
|
||||
aiModelListResponseSchema,
|
||||
aiStatusResponseSchema,
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
type AiOpenrouterFinishRequest,
|
||||
type AiPrivacyAckRequest,
|
||||
} from '@trek/shared';
|
||||
import { aiAdminApi, aiApi, claudeConnectorApi } from '../api/assistant';
|
||||
import { aiAdminApi, aiApi } from '../api/assistant';
|
||||
import { requireOnline } from './onlineOnly';
|
||||
|
||||
/**
|
||||
@@ -114,11 +113,4 @@ export const aiConnectionsRepo = {
|
||||
requireOnline();
|
||||
return aiModelListResponseSchema.parse(await aiAdminApi.models()).models;
|
||||
},
|
||||
|
||||
// ── Claude connector ───────────────────────────────────────────────────────
|
||||
|
||||
async claudeConnector() {
|
||||
requireOnline();
|
||||
return aiClaudeConnectorResponseSchema.parse(await claudeConnectorApi.get());
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,11 +22,11 @@ describe('parseReleaseNotes', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-UTIL-RELNOTES-003: lines are trimmed and markers need their space', () => {
|
||||
it('FE-UTIL-RELNOTES-003: lines are trimmed, markers need their space and #### heads a feature', () => {
|
||||
expect(parseReleaseNotes(' ## Spaced \n-no space\n#### deep\n\n\n')).toEqual([
|
||||
{ kind: 'h3', text: 'Spaced' },
|
||||
{ kind: 'p', text: '-no space' },
|
||||
{ kind: 'p', text: '#### deep' },
|
||||
{ kind: 'h5', text: 'deep' },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** One block of a release body: a heading, a paragraph, or a run of list items. */
|
||||
export type ReleaseNoteBlock = { kind: 'h3' | 'h4' | 'p'; text: string } | { kind: 'ul'; items: string[] };
|
||||
export type ReleaseNoteBlock = { kind: 'h3' | 'h4' | 'h5' | 'p'; text: string } | { kind: 'ul'; items: string[] };
|
||||
|
||||
/** Escapes the characters that would let release text break out of the HTML it is placed in. */
|
||||
export function escapeReleaseHtml(str: string): string {
|
||||
@@ -7,8 +7,8 @@ export function escapeReleaseHtml(str: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a GitHub release body into the blocks both admin shells render: `### ` and `## `
|
||||
* headings, `- ` or `* ` list items gathered into one list until anything else breaks the
|
||||
* Splits a GitHub release body into the blocks both admin shells render: `## `, `### ` and
|
||||
* `#### ` headings (the version, a section such as Added, a feature inside it), `- ` or `* ` list items gathered into one list until anything else breaks the
|
||||
* run, and every other non-empty line as a paragraph. Blank lines only end a list. The
|
||||
* text stays raw; each shell formats it inline itself.
|
||||
*/
|
||||
@@ -30,7 +30,10 @@ export function parseReleaseNotes(body: string): ReleaseNoteBlock[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('### ')) {
|
||||
if (trimmed.startsWith('#### ')) {
|
||||
flushList();
|
||||
blocks.push({ kind: 'h5', text: trimmed.slice(5) });
|
||||
} else if (trimmed.startsWith('### ')) {
|
||||
flushList();
|
||||
blocks.push({ kind: 'h4', text: trimmed.slice(4) });
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
|
||||
@@ -179,12 +179,12 @@ describe('applying a theme', () => {
|
||||
});
|
||||
|
||||
describe('the Theme panel', () => {
|
||||
it('FE-STUDIO-THEME-007: shows twelve themes, applies the one picked and says so', () => {
|
||||
it('FE-STUDIO-THEME-007: shows seventeen themes, applies the one picked and says so', () => {
|
||||
render(<StudioThemePanel t={t} />);
|
||||
const cards = screen
|
||||
.getAllByRole('button', { pressed: false })
|
||||
.filter((b) => b.classList.contains('st-theme-card'));
|
||||
expect(cards).toHaveLength(11);
|
||||
expect(cards).toHaveLength(16);
|
||||
fireEvent.click(screen.getByTitle('studio.theme.descr.riviera'));
|
||||
fireEvent.click(screen.getByText('studio.theme.applyBook'));
|
||||
expect(doc().theme?.id).toBe('riviera');
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
"src/nest/plugins/runtime/plugin-sdk.ts": 1093,
|
||||
"src/nest/reservations/reservations.service.ts": 1237,
|
||||
"src/nest/trips/trips.service.ts": 1006,
|
||||
"src/nest/vacay/vacay.service.ts": 1416
|
||||
"src/nest/vacay/vacay.service.ts": 1409
|
||||
}
|
||||
|
||||
@@ -46,6 +46,11 @@ Recipes, for a cover built or tweaked by hand, as JSON: each object one element
|
||||
- grid-badge: [{"kind":"photo","photoId":"cover","frame":[-2,-2,50.8,50.8]},{"kind":"photo","photoId":"2","frame":[51.2,-2,50.8,50.8]},{"kind":"photo","photoId":"3","frame":[-2,51.2,50.8,50.8]},{"kind":"photo","photoId":"4","frame":[51.2,51.2,50.8,50.8]},{"lockup":"circle-badge","box":[29,29,42,42]}], a cross of paper 2.4 % (5 mm) wide, the badge off every photo's focal point.
|
||||
- minimal-serif: [{"kind":"photo","photoId":"cover","frame":[8,8,84,53],"border":{"width":0.3,"color":"ink"}},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":80,"leading":1,"frame":[8,64,84,17]},{"kind":"text","binding":{"source":"journey.subtitle"},"fontRole":"heading","token":"ink","size":11,"frame":[8,82,84,3]},{"kind":"shape","shape":"rect","token":"ink","frame":[28,88.4,20,0.15]},{"kind":"shape","shape":"sun","token":"accent","frame":[48.5,87,3,3]},{"kind":"shape","shape":"rect","token":"ink","frame":[52,88.4,20,0.15]},{"kind":"text","text":"2026","size":8,"frame":[8,92,84,3]}]
|
||||
- stamp-collage: [{"kind":"shape","shape":"rect","paint":{"type":"pattern","pattern":"checker","fg":"accent","bg":"paper","scale":1,"angle":0},"frame":[-2,-2,104,36]},{"kind":"photo","photoId":"cover","frame":[8,19,26,31],"rotation":-4,"edge":"perforated","edgeSize":3,"border":{"width":2.2,"color":"#ffffff"}},{"kind":"photo","photoId":"2","frame":[37,17,26,34],"rotation":3,"mask":"arch-tall","cutout":{"width":1.8,"color":"#ffffff"}},{"kind":"photo","photoId":"3","frame":[66,19,26,31],"rotation":-3,"edge":"perforated","edgeSize":3,"border":{"width":2.2,"color":"#ffffff"}},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":90,"weight":800,"transform":"upper","align":"left","frame":[8,62,52,18]},{"kind":"text","text":"May 2026","size":9,"frame":[8,81,52,4]},{"lockup":"postmark","box":[62,60,30,30]}]
|
||||
- riso: [{"kind":"shape","shape":"ellipse","token":"accent2","opacity":0.85,"frame":[8,46,52,52]},{"kind":"shape","shape":"ellipse","paint":{"type":"pattern","pattern":"dots","fg":"accent","bg":null,"scale":0.4,"angle":45},"frame":[11,44,52,52]},{"kind":"shape","shape":"rect","token":"accent","frame":[37,50,52,42],"rotation":2},{"kind":"photo","photoId":"cover","frame":[39,48,52,42],"rotation":2},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":90,"transform":"upper","leading":0.92,"align":"left","shadow":{"dx":1.6,"dy":1.6,"color":"accent","alpha":1},"frame":[8,8,84,30]},{"kind":"text","text":"2026","size":9,"highlight":{"color":"accent2"},"frame":[8,88,20,4]}], the block and the discs a few mm out of register.
|
||||
- editorial: [{"kind":"photo","photoId":"cover","frame":[-2,37,104,65]},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":96,"weight":800,"transform":"upper","leading":0.9,"frame":[8,8,84,19]},{"kind":"shape","shape":"rect","token":"ink","frame":[8,27.5,84,0.2]},{"kind":"text","text":"May 2026","size":8,"align":"left","frame":[8,29,40,3]},{"kind":"shape","shape":"rect","token":"accent","frame":[8,74,44,18]},{"kind":"text","binding":{"source":"journey.subtitle"},"fontRole":"heading","token":"paper","size":16,"align":"left","frame":[12,78,36,10]}], the block off the photo's focal point.
|
||||
- vintage-poster: [{"kind":"shape","shape":"rect","fill":null,"strokeToken":"ink","strokeWidth":0.6,"frame":[1.7,1.7,96.6,96.6]},{"kind":"shape","shape":"rect","token":"accent3","frame":[3.3,3.3,93.4,58]},{"kind":"shape","shape":"sunburst-24","token":"accent","opacity":0.5,"frame":[24,6,52,52]},{"kind":"shape","shape":"ellipse","token":"accent","frame":[34,16,32,32]},{"kind":"shape","shape":"mountain","token":"accent2","frame":[3.3,32,64,29.3]},{"kind":"shape","shape":"mountain","token":"ink","opacity":0.8,"frame":[36,40,60.7,21.3]},{"kind":"photo","photoId":"cover","frame":[38,14,24,44],"mask":"arch-tall","cutout":{"width":2,"color":"#ffffff"}},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":80,"transform":"upper","leading":0.92,"frame":[8,66,84,20]},{"kind":"text","text":"May 2026","size":9,"frame":[8,88,84,4]}]
|
||||
- night-film: [{"background":"ink"},{"kind":"text","text":"May 2026","font":"mono","token":"accent","size":8,"tracking":0.2,"align":"left","frame":[8,8,40,3]},{"kind":"photo","photoId":"cover","frame":[-2,14,104,40]},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"paper","size":72,"transform":"upper","tracking":0.14,"align":"left","frame":[8,58,84,16]},{"kind":"shape","shape":"rect","token":"accent","frame":[8,76,16,0.4]},{"kind":"shape","shape":"rect","token":"paper","opacity":0.12,"frame":[-2,80,104,18]},{"kind":"line","stroke":"#f2efe8","strokeWidth":1.6,"dash":"dashed","frame":[-2,81.5,104,1]},{"kind":"line","stroke":"#f2efe8","strokeWidth":1.6,"dash":"dashed","frame":[-2,95.5,104,1]},{"kind":"photo","photoId":"2","frame":[8,84,26,10]},{"kind":"photo","photoId":"3","frame":[37,84,26,10]},{"kind":"photo","photoId":"4","frame":[66,84,26,10]}], paper and ink swapped in a dark theme.
|
||||
- field-notes: [{"kind":"shape","shape":"rect","paint":{"type":"pattern","pattern":"grid","fg":"accent3","bg":"accent2","scale":0.6,"angle":0},"frame":[4,4,92,92],"rotation":-1.5},{"kind":"shape","shape":"leaf-long","token":"accent3","frame":[2,40,9,40],"rotation":-20},{"kind":"photo","photoId":"cover","frame":[10,10,48,40],"rotation":-3,"border":{"width":2.6,"color":"#ffffff"}},{"kind":"shape","shape":"rect","token":"accent2","opacity":0.72,"edge":"zigzag","edgeSize":1,"frame":[24,8,19,3.3],"rotation":4},{"kind":"photo","photoId":"2","frame":[62,17,28,27],"rotation":4,"border":{"width":2.6,"color":"#ffffff"}},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":60,"align":"left","frame":[8,62,46,18]},{"kind":"text","binding":{"source":"journey.subtitle"},"fontRole":"script","token":"accent","size":26,"align":"left","frame":[8,80,46,8]},{"kind":"text","text":"May 2026","font":"mono","size":8,"align":"left","frame":[8,89,46,3]}]
|
||||
Lively spreads, in percent of the double page (the fold at 50); photos 1 to 4 are the page's, entry its entry id:
|
||||
- script overlay: [{"kind":"photo","photoId":"1","focalX":0.3,"frame":[-1,-2,102,104]},{"kind":"text","binding":{"source":"entry.location","entryId":"entry"},"fontRole":"script","size":120,"color":"#ffffff","outline":{"color":"#1a1a1a","width":2.9},"frame":[55,26,40,30],"rotation":-6},{"kind":"text","binding":{"source":"entry.title","entryId":"entry"},"token":"ink","size":9,"highlight":{"color":"paper"},"frame":[57,62,36,4]}], the photo's detail off the fold and the word.
|
||||
- collage: [{"kind":"photo","photoId":"1","frameStyle":"polaroid","frame":[6,10,26,36],"rotation":-5},{"kind":"photo","photoId":"2","frameStyle":"white","frame":[22,42,24,32],"rotation":4},{"kind":"photo","photoId":"3","frameStyle":"tape","frame":[56,12,30,40],"rotation":-3},{"kind":"photo","photoId":"4","frameStyle":"white","frame":[66,52,26,34],"rotation":6},{"kind":"sticker","stickerId":"motif","frame":[39,8,7,10],"rotation":8}], up to 3 prints a page turned 3 to 7 degrees either way, none across the fold or over another's focal point.
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { AddonGuard } from '../addons/addon.guard';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { ClaudeConnectorService } from './claude-connector.service';
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { aiClaudeConnectorResponseSchema, type AiClaudeConnectorResponse } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* /api/integrations/claude-connector — what the settings card needs to add
|
||||
* TREK to claude.ai as a custom connector. It follows the MCP addon (the
|
||||
* connector is the MCP endpoint), not the in-app assistant's addon.
|
||||
*/
|
||||
@Controller('api/integrations/claude-connector')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard)
|
||||
@RequireAddon(ADDON_IDS.MCP, 'MCP')
|
||||
export class ClaudeConnectorController {
|
||||
constructor(private readonly connector: ClaudeConnectorService) {}
|
||||
|
||||
@Get()
|
||||
@ResponseContract(aiClaudeConnectorResponseSchema)
|
||||
async get(): Promise<AiClaudeConnectorResponse> {
|
||||
return await this.connector.describe();
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { OauthService } from './oauth.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { AiClaudeConnectorResponse } from '@trek/shared';
|
||||
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
/** claude.ai's "add custom connector" dialog, prefilled through its query string. */
|
||||
const CLAUDE_ADD_CONNECTOR_URL = 'https://claude.ai/customize/connectors';
|
||||
|
||||
/**
|
||||
* "Connect with Claude": the link that opens claude.ai's custom-connector
|
||||
* dialog with this instance's MCP endpoint filled in, and the same link as a QR
|
||||
* code for the phone. claude.ai then runs the ordinary OAuth flow against
|
||||
* /oauth/authorize; nothing here touches the user's data or calls Anthropic.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ClaudeConnectorService {
|
||||
constructor(private readonly oauth: OauthService) {}
|
||||
|
||||
async describe(): Promise<AiClaudeConnectorResponse> {
|
||||
const mcpUrl = `${this.oauth.mcpSafeUrl().replace(/(?<!\/)\/+$/, '')}/mcp`;
|
||||
const params = new URLSearchParams({
|
||||
modal: 'add-custom-connector',
|
||||
connectorName: 'TREK',
|
||||
connectorUrl: mcpUrl,
|
||||
});
|
||||
const deepLink = `${CLAUDE_ADD_CONNECTOR_URL}?${params.toString()}`;
|
||||
const qrSvg = await QRCode.toString(deepLink, { type: 'svg', margin: 1 });
|
||||
return { mcpUrl, deepLink, qrSvg };
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import { createMcpAddonGate } from '../addons/mcp-addon-gate';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { RateLimitModule } from '../common/rate-limit.module';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
import { ClaudeConnectorController } from './claude-connector.controller';
|
||||
import { ClaudeConnectorService } from './claude-connector.service';
|
||||
import { OauthApiController } from './oauth-api.controller';
|
||||
import { CimdClientResolver } from './oauth-cimd';
|
||||
import { OauthPublicController } from './oauth-public.controller';
|
||||
@@ -49,8 +47,7 @@ import type { MiddlewareConsumer, NestModule } from '@nestjs/common';
|
||||
* swapped provider too.
|
||||
*
|
||||
* Client ID Metadata Documents (oauth-cimd.ts) resolve through the injected
|
||||
* CimdClientResolver; ClaudeConnectorController hands the settings page the
|
||||
* claude.ai link to this instance's MCP endpoint.
|
||||
* CimdClientResolver.
|
||||
*
|
||||
* Exports OauthService for AdminController (admin OAuth-session panel) and the
|
||||
* MCP transport's token verification.
|
||||
@@ -73,11 +70,10 @@ import type { MiddlewareConsumer, NestModule } from '@nestjs/common';
|
||||
SchedulingModule,
|
||||
MikroOrmModule.forFeature([OauthClients, OauthTokens, OauthConsents, Users]),
|
||||
],
|
||||
controllers: [OauthPublicController, OauthApiController, ClaudeConnectorController],
|
||||
controllers: [OauthPublicController, OauthApiController],
|
||||
providers: [
|
||||
OauthService,
|
||||
CimdClientResolver,
|
||||
ClaudeConnectorService,
|
||||
TrekClientsStore,
|
||||
TrekOAuthProvider,
|
||||
OauthTokenRetentionJob,
|
||||
|
||||
@@ -69,3 +69,35 @@ export function shiftIsoDate(isoDate: string, days: number): string {
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export const CACHE_TTL = 24 * 60 * 60 * 1000;
|
||||
export const FETCH_TIMEOUT_MS = 10_000;
|
||||
// A country's holidays for one year are a few kilobytes; two megabytes means the
|
||||
// provider is misbehaving and we would rather report the usual error than buffer it.
|
||||
export const MAX_HOLIDAY_BYTES = 2 * 1024 * 1024;
|
||||
// Both providers key on a 4-digit year and an ISO 3166-1 alpha-2 code. Anything
|
||||
// else has no business reaching the URL path.
|
||||
export const YEAR_RE = /^\d{4}$/;
|
||||
export const COUNTRY_RE = /^[A-Za-z]{2}$/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color palette for auto-assign
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const COLORS = [
|
||||
'#6366f1',
|
||||
'#ec4899',
|
||||
'#14b8a6',
|
||||
'#8b5cf6',
|
||||
'#ef4444',
|
||||
'#3b82f6',
|
||||
'#22c55e',
|
||||
'#06b6d4',
|
||||
'#f43f5e',
|
||||
'#a855f7',
|
||||
'#10b981',
|
||||
'#0ea5e9',
|
||||
'#64748b',
|
||||
'#be185d',
|
||||
'#0d9488',
|
||||
];
|
||||
|
||||
@@ -30,7 +30,7 @@ import { SchoolHolidayRegions } from '../../db/entities/SchoolHolidayRegions.ent
|
||||
import type { SchoolHolidayRegionsRepository } from '../../db/repositories/SchoolHolidayRegions.repository';
|
||||
import {
|
||||
daysAlwaysInMonth, isBlockedWeekend, normalizeFraction, normalizeKind, normalizeYearType, pad2, parseAsUtcMillis,
|
||||
shiftIsoDate, windowEndYear,
|
||||
shiftIsoDate, windowEndYear, CACHE_TTL, COLORS, COUNTRY_RE, FETCH_TIMEOUT_MS, MAX_HOLIDAY_BYTES, YEAR_RE,
|
||||
} from './vacay.helpers';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -125,26 +125,6 @@ export interface VacayShare {
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
const CACHE_TTL = 24 * 60 * 60 * 1000;
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
// A country's holidays for one year are a few kilobytes; two megabytes means the
|
||||
// provider is misbehaving and we would rather report the usual error than buffer it.
|
||||
const MAX_HOLIDAY_BYTES = 2 * 1024 * 1024;
|
||||
// Both providers key on a 4-digit year and an ISO 3166-1 alpha-2 code. Anything
|
||||
// else has no business reaching the URL path.
|
||||
const YEAR_RE = /^\d{4}$/;
|
||||
const COUNTRY_RE = /^[A-Za-z]{2}$/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color palette for auto-assign
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const COLORS = [
|
||||
'#6366f1', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444',
|
||||
'#3b82f6', '#22c55e', '#06b6d4', '#f43f5e', '#a855f7',
|
||||
'#10b981', '#0ea5e9', '#64748b', '#be185d', '#0d9488',
|
||||
];
|
||||
|
||||
/**
|
||||
* Vacay domain service — owns the vacay SQL, now through the ten vacay
|
||||
* repositories (+ `SchoolHolidayRegionsRepository`'s cross-domain read,
|
||||
@@ -1020,37 +1000,38 @@ export class VacayService {
|
||||
}
|
||||
|
||||
async addYear(planId: number, year: number, socketId: string | undefined): Promise<number[]> {
|
||||
// A duplicate year is a no-op (the legacy blanket try/catch was written for
|
||||
// exactly this constraint hit); real errors now propagate instead of being
|
||||
// swallowed. The insert + per-user seeding runs atomically.
|
||||
const exists = await this.years.exists(planId, year);
|
||||
if (!exists) {
|
||||
await this.uow.transactional(async () => {
|
||||
await this.years.insertYear(planId, year);
|
||||
const plan = await this.plans.findById(planId);
|
||||
const carryOverEnabled = plan ? !!plan.carry_over_enabled : true;
|
||||
const users = await this.getPlanUsers(planId);
|
||||
for (const u of users) {
|
||||
let carriedOver = 0;
|
||||
if (carryOverEnabled) {
|
||||
const prevConfig = await this.userYears.findForYear(u.id, planId, year - 1);
|
||||
if (prevConfig) {
|
||||
const used = await this.usedDays(u.id, planId, year - 1);
|
||||
// L1 (task-7-review.md): `?? 0`, not `?? 30` — reproduces
|
||||
// legacy's raw `prevConfig.vacation_days + prevConfig.carried_over`,
|
||||
// where a NULL vacation_days coerced to 0 in the `+`, never to 30.
|
||||
const total = (prevConfig.vacation_days ?? 0) + (prevConfig.carried_over ?? 0);
|
||||
carriedOver = Math.max(0, total - used);
|
||||
}
|
||||
}
|
||||
await this.userYears.insertIgnore(u.id, planId, year, 30, carriedOver);
|
||||
}
|
||||
});
|
||||
}
|
||||
// A duplicate year is a no-op (the legacy blanket try/catch was written for exactly this
|
||||
// constraint hit); real errors propagate. The insert + per-user seeding runs atomically.
|
||||
if (!(await this.years.exists(planId, year))) await this.openYear(planId, year);
|
||||
await this.notifyPlanUsers(planId, socketId, 'vacay:settings');
|
||||
return await this.listYears(planId);
|
||||
}
|
||||
|
||||
/** The year's row and every member's allowance for it, with no broadcast: the caller sends it after its commit. */
|
||||
private async openYear(planId: number, year: number): Promise<void> {
|
||||
await this.uow.transactional(async () => {
|
||||
await this.years.insertYear(planId, year);
|
||||
const plan = await this.plans.findById(planId);
|
||||
const carryOverEnabled = plan ? !!plan.carry_over_enabled : true;
|
||||
const users = await this.getPlanUsers(planId);
|
||||
for (const u of users) {
|
||||
let carriedOver = 0;
|
||||
if (carryOverEnabled) {
|
||||
const prevConfig = await this.userYears.findForYear(u.id, planId, year - 1);
|
||||
if (prevConfig) {
|
||||
const used = await this.usedDays(u.id, planId, year - 1);
|
||||
// L1 (task-7-review.md): `?? 0`, not `?? 30` — reproduces
|
||||
// legacy's raw `prevConfig.vacation_days + prevConfig.carried_over`,
|
||||
// where a NULL vacation_days coerced to 0 in the `+`, never to 30.
|
||||
const total = (prevConfig.vacation_days ?? 0) + (prevConfig.carried_over ?? 0);
|
||||
carriedOver = Math.max(0, total - used);
|
||||
}
|
||||
}
|
||||
await this.userYears.insertIgnore(u.id, planId, year, 30, carriedOver);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteYear(planId: number, year: number, socketId: string | undefined): Promise<number[]> {
|
||||
// Year removal deletes across four tables and recomputes the next year's
|
||||
// carry-over — atomic, so a failure can't leave entries without their year.
|
||||
@@ -1139,25 +1120,32 @@ export class VacayService {
|
||||
const knd = normalizeKind(kind);
|
||||
const weekendBlocked = plan ? isBlockedWeekend(plan, date) : false;
|
||||
const existing = await this.entries.findByUserDatePlan(userId, date, planId);
|
||||
if (existing) {
|
||||
// Clicking the exact same day again (same type AND same fraction) clears it;
|
||||
// clicking a different type or fraction converts it in place (#552/#1074).
|
||||
if (existing.fraction === frac && (existing.kind || 'vacation') === knd) {
|
||||
await this.entries.deleteById(existing.id);
|
||||
await this.notifyPlanUsers(planId, socketId);
|
||||
return { action: 'removed' };
|
||||
}
|
||||
// Removing a stray entry on a blocked day stays possible; keeping one
|
||||
// there (converted in place) does not.
|
||||
if (weekendBlocked) return { error: 'weekend_blocked' };
|
||||
await this.entries.updateFractionKind(existing.id, frac, knd);
|
||||
// Clicking the exact same day again (same type AND same fraction) clears it;
|
||||
// clicking a different type or fraction converts it in place (#552/#1074).
|
||||
if (existing && existing.fraction === frac && (existing.kind || 'vacation') === knd) {
|
||||
await this.entries.deleteById(existing.id);
|
||||
await this.notifyPlanUsers(planId, socketId);
|
||||
return { action: 'updated', fraction: frac, kind: knd };
|
||||
return { action: 'removed' };
|
||||
}
|
||||
// Removing a stray entry on a blocked day stays possible; keeping one there does not.
|
||||
if (weekendBlocked) return { error: 'weekend_blocked' };
|
||||
await this.entries.insertEntry(planId, userId, date, '', frac, knd);
|
||||
const opened = await this.uow.transactional(async () => {
|
||||
if (existing) await this.entries.updateFractionKind(existing.id, frac, knd);
|
||||
else await this.entries.insertEntry(planId, userId, date, '', frac, knd);
|
||||
return this.ensureYearOf(planId, userId, date);
|
||||
});
|
||||
if (opened) await this.notifyPlanUsers(planId, socketId, 'vacay:settings');
|
||||
await this.notifyPlanUsers(planId, socketId);
|
||||
return { action: 'added', fraction: frac, kind: knd };
|
||||
return { action: existing ? 'updated' : 'added', fraction: frac, kind: knd };
|
||||
}
|
||||
|
||||
/** A day written for a year the plan has not opened adds that year, or the calendar never shows it (#737 picks the period). */
|
||||
private async ensureYearOf(planId: number, userId: number, date: string): Promise<boolean> {
|
||||
const [y, m, d] = date.split('-').map(Number);
|
||||
const year = await this.currentPeriodYear(userId, new Date(y, m - 1, d));
|
||||
if (await this.years.exists(planId, year)) return false;
|
||||
await this.openYear(planId, year);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1167,6 +1155,7 @@ export class VacayService {
|
||||
*/
|
||||
async toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined, fraction?: unknown): Promise<{ action: string; fraction?: number }> {
|
||||
const frac = normalizeFraction(fraction);
|
||||
let opened = false;
|
||||
const result = await this.uow.transactional(async () => {
|
||||
const existing = await this.companyHolidays.findByPlanAndDate(planId, date); // VC118
|
||||
if (existing && existing.fraction === frac) {
|
||||
@@ -1179,8 +1168,11 @@ export class VacayService {
|
||||
await this.companyHolidays.insertHoliday(planId, date, note || '', frac); // VC120
|
||||
}
|
||||
await this.makeRoomForCompanyHoliday(planId, date, frac);
|
||||
const owner = await this.plans.findOwnerId(planId);
|
||||
if (owner) opened = await this.ensureYearOf(planId, owner.owner_id, date);
|
||||
return { action: existing ? 'updated' : 'added', fraction: frac };
|
||||
});
|
||||
if (opened) await this.notifyPlanUsers(planId, socketId, 'vacay:settings');
|
||||
await this.notifyPlanUsers(planId, socketId);
|
||||
return result;
|
||||
}
|
||||
|
||||