feat: europeanize mock JMAP data and refine email list layout

Replace all mock data in the dev JMAP server with European-themed
content: 42 emails (was 13), 20 contacts across 2 address books
(was 3), 5 calendars and 35 events with recurrence, overlaps,
and all-day entries.

Move keyword tags in email/thread list items from the subject line
to the header row alongside the date for a cleaner layout.

Update test assertions to match the new mock data.
This commit is contained in:
Linus Rath
2026-03-11 20:21:39 +01:00
parent 454d51e283
commit e5c1cd45c5
4 changed files with 1102 additions and 238 deletions
File diff suppressed because one or more lines are too long
+27 -27
View File
@@ -147,35 +147,35 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
)} )}
</div> </div>
</div> </div>
<span className={cn( <div className="flex items-center gap-1.5 flex-shrink-0">
"text-xs flex-shrink-0 tabular-nums", {keywordDef && (
isUnread <span className={cn(
? "text-foreground font-semibold" "inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
: "text-muted-foreground" KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}> )}>
{formatDate(email.receivedAt)} <span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
</span> {keywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div> </div>
{/* Second Line: Subject + Tag */} {/* Second Line: Subject */}
<div className="flex items-center gap-1.5 mb-1"> <div className={cn(
<span className={cn( "mb-1 line-clamp-1 text-sm",
"line-clamp-1 text-sm min-w-0", isUnread
isUnread ? "font-semibold text-foreground"
? "font-semibold text-foreground" : "font-normal text-foreground/90"
: "font-normal text-foreground/90" )}>
)}> {email.subject || t('no_subject')}
{email.subject || t('no_subject')}
</span>
{keywordDef && (
<span className={cn(
"flex-shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
)}
</div> </div>
{/* Third Line: Preview (controlled by showPreview setting) */} {/* Third Line: Preview (controlled by showPreview setting) */}
+52 -52
View File
@@ -129,34 +129,34 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)} )}
</div> </div>
</div> </div>
<span className={cn( <div className="flex items-center gap-1.5 flex-shrink-0">
"text-xs flex-shrink-0 tabular-nums", {resolvedKeywordDef && (
isUnread <span className={cn(
? "text-foreground font-semibold" "inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
: "text-muted-foreground" KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
)}> )}>
{formatDate(email.receivedAt)} <span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
</span> {resolvedKeywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div> </div>
<div className="flex items-center gap-1.5 mb-1"> <div className={cn(
<span className={cn( "mb-1 line-clamp-1 text-sm",
"line-clamp-1 text-sm min-w-0", isUnread
isUnread ? "font-semibold text-foreground"
? "font-semibold text-foreground" : "font-normal text-foreground/90"
: "font-normal text-foreground/90" )}>
)}> {email.subject || "(no subject)"}
{email.subject || "(no subject)"}
</span>
{resolvedKeywordDef && (
<span className={cn(
"flex-shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
{resolvedKeywordDef.label}
</span>
)}
</div> </div>
{showPreview && ( {showPreview && (
@@ -354,34 +354,34 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)} )}
</div> </div>
</div> </div>
<span className={cn( <div className="flex items-center gap-1.5 flex-shrink-0">
"text-xs flex-shrink-0 tabular-nums", {keywordDef && (
hasUnread <span className={cn(
? "text-foreground font-semibold" "inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
: "text-muted-foreground" KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}> )}>
{formatDate(latestEmail.receivedAt)} <span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
</span> {keywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
hasUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(latestEmail.receivedAt)}
</span>
</div>
</div> </div>
<div className="flex items-center gap-1.5 mb-1"> <div className={cn(
<span className={cn( "mb-1 line-clamp-1 text-sm",
"line-clamp-1 text-sm min-w-0", hasUnread
hasUnread ? "font-semibold text-foreground"
? "font-semibold text-foreground" : "font-normal text-foreground/90"
: "font-normal text-foreground/90" )}>
)}> {latestEmail.subject || "(no subject)"}
{latestEmail.subject || "(no subject)"}
</span>
{keywordDef && (
<span className={cn(
"flex-shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
)}
</div> </div>
{showPreview && ( {showPreview && (
+5 -5
View File
@@ -47,13 +47,13 @@ describe('dev-jmap mock server', () => {
describe('GET /download', () => { describe('GET /download', () => {
it('should return a response with attachment disposition', async () => { it('should return a response with attachment disposition', async () => {
const req = makeRequest('http://localhost:3000/api/dev-jmap/download/dev-account-001/blob-att-001/Q1-Report.pdf?accept=application/pdf', { const req = makeRequest('http://localhost:3000/api/dev-jmap/download/dev-account-001/blob-att-001/Q1-Bericht.pdf?accept=application/pdf', {
headers: { host: 'localhost:3000' }, headers: { host: 'localhost:3000' },
}); });
const res = await GET(req, { params: Promise.resolve({ path: ['download', 'dev-account-001', 'blob-att-001', 'Q1-Report.pdf'] }) }); const res = await GET(req, { params: Promise.resolve({ path: ['download', 'dev-account-001', 'blob-att-001', 'Q1-Bericht.pdf'] }) });
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toBe('application/pdf'); expect(res.headers.get('Content-Type')).toBe('application/pdf');
expect(res.headers.get('Content-Disposition')).toContain('Q1-Report.pdf'); expect(res.headers.get('Content-Disposition')).toContain('Q1-Bericht.pdf');
}); });
}); });
@@ -108,7 +108,7 @@ describe('dev-jmap mock server', () => {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' }, headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
body: JSON.stringify({ body: JSON.stringify({
methodCalls: [['Email/query', { accountId: 'dev-account-001', filter: { text: 'welcome' } }, 'c0']], methodCalls: [['Email/query', { accountId: 'dev-account-001', filter: { text: 'willkommen' } }, 'c0']],
}), }),
}); });
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) }); const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
@@ -130,7 +130,7 @@ describe('dev-jmap mock server', () => {
const data = await res.json(); const data = await res.json();
expect(data.methodResponses[0][0]).toBe('Email/get'); expect(data.methodResponses[0][0]).toBe('Email/get');
expect(data.methodResponses[0][1].list).toHaveLength(1); expect(data.methodResponses[0][1].list).toHaveLength(1);
expect(data.methodResponses[0][1].list[0].subject).toContain('Welcome'); expect(data.methodResponses[0][1].list[0].subject).toContain('Willkommen');
}); });
it('should filter properties when specified', async () => { it('should filter properties when specified', async () => {