Compare commits

..

6 Commits

Author SHA1 Message Date
Marcel
643d504c7a fix(docker): bump frontend image to Node 22 for pdfjs-dist engine requirement
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m51s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m41s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
CI / Unit & Component Tests (push) Successful in 3m41s
CI / OCR Service Tests (push) Successful in 21s
CI / fail2ban Regex (push) Has been cancelled
CI / Semgrep Security Scan (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
pdfjs-dist resolves to 5.7.284, which requires Node >=22.13.0 || >=24.
With engine-strict=true in .npmrc, npm ci hard-fails on the Node 20 base
image, so the frontend dev server crash-loops (and a clean build fails).
CI runs the frontend on Node 22 (Playwright image), so the committed
lockfile already assumes 22. Bump all three Dockerfile stages to match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:46:16 +02:00
Marcel
c9f5f6d665 fix(docker): point dev backend healthcheck at management port 8081
The observability work moved actuator to a separate management port
(management.server.port: 8081), but the dev compose healthcheck still
probed :8080/actuator/health, which 404s. The backend was reported
unhealthy and the frontend (depends_on: backend healthy) never started.
docker-compose.prod.yml already uses 8081; this aligns dev with it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:45:51 +02:00
Marcel
3f3d5e530c test(dashboard): add missing tag tree mock to recentDocs reader test
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m42s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m40s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
CI / Unit & Component Tests (push) Successful in 4m5s
CI / OCR Service Tests (push) Successful in 22s
CI / Backend Unit Tests (push) Successful in 3m38s
CI / fail2ban Regex (push) Successful in 42s
CI / Semgrep Security Scan (push) Successful in 19s
CI / Compose Bucket Idempotency (push) Successful in 1m2s
nightly / deploy-staging (push) Successful in 2m14s
The sequential mock chain in the recentDocs test was missing a 6th call
for /api/tags/tree added in the tag tree fetch. Without it the mock
returned undefined, causing settled() to throw and the outer catch to
return an empty recentDocs array.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 19:45:28 +02:00
Marcel
5dac1d993c fix(themen): correct link color and tag navigation route
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m18s
CI / OCR Service Tests (pull_request) Successful in 19s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m2s
- Match "Alle Themen →" link style to other reader dashboard widgets (text-ink-2, font-semibold, no-underline)
- Fix tag card hrefs from /?tag= to /documents?tag= — the home page does not handle tag filtering, /documents does

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 19:29:53 +02:00
Marcel
264d60c855 feat(themen): cap ThemenWidget at 6 tags — link to /themen for full list
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / OCR Service Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / fail2ban Regex (pull_request) Has been cancelled
CI / Semgrep Security Scan (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 19:06:56 +02:00
Marcel
e6a0c2f6d6 feat(dashboard): move ThemenWidget to full-width position
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m27s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 4m5s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
Editor view: lifted out of sidebar, now spans full width between
DashboardResumeStrip and EnrichmentBlock.
Reader view: already below ReaderPersonChips, no change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 19:03:47 +02:00
6 changed files with 45 additions and 38 deletions

View File

@@ -201,7 +201,7 @@ services:
networks: networks:
- archiv-net - archiv-net
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health | grep -q UP || exit 1"] test: ["CMD-SHELL", "wget -qO- http://localhost:8081/actuator/health | grep -q UP || exit 1"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 10 retries: 10

View File

@@ -4,7 +4,7 @@
# Used by docker-compose.yml (target: development). Source is bind-mounted in # Used by docker-compose.yml (target: development). Source is bind-mounted in
# dev so the COPY . below is effectively replaced at runtime; the layer still # dev so the COPY . below is effectively replaced at runtime; the layer still
# exists so the image is self-contained for cold starts (e.g. devcontainer). # exists so the image is self-contained for cold starts (e.g. devcontainer).
FROM node:20.19.0-alpine3.21 AS development FROM node:22-alpine3.21 AS development
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
@@ -14,7 +14,7 @@ CMD ["npm", "run", "dev"]
# ── Build ──────────────────────────────────────────────────────────────────── # ── Build ────────────────────────────────────────────────────────────────────
# Compiles the SvelteKit Node-adapter output to /app/build. # Compiles the SvelteKit Node-adapter output to /app/build.
FROM node:20.19.0-alpine3.21 AS build FROM node:22-alpine3.21 AS build
WORKDIR /app WORKDIR /app
# VITE_SENTRY_DSN is a build-time variable — Vite bakes it into the bundle. # VITE_SENTRY_DSN is a build-time variable — Vite bakes it into the bundle.
# Passed via docker-compose build.args; empty string disables the SDK. # Passed via docker-compose build.args; empty string disables the SDK.
@@ -27,7 +27,7 @@ RUN npm run build
# ── Production ─────────────────────────────────────────────────────────────── # ── Production ───────────────────────────────────────────────────────────────
# Self-contained Node server. `node build` is the adapter-node entrypoint. # Self-contained Node server. `node build` is the adapter-node entrypoint.
FROM node:20.19.0-alpine3.21 AS production FROM node:22-alpine3.21 AS production
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
COPY --from=build /app/build ./build COPY --from=build /app/build ./build

View File

@@ -10,9 +10,12 @@ interface Props {
compact?: boolean; compact?: boolean;
} }
const MAX_VISIBLE_TAGS = 6;
const { tags, compact = false }: Props = $props(); const { tags, compact = false }: Props = $props();
const visibleTags = $derived.by(() => tags.filter(hasAnyDocuments)); const visibleTags = $derived.by(() => tags.filter(hasAnyDocuments));
const shownTags = $derived(visibleTags.slice(0, MAX_VISIBLE_TAGS));
</script> </script>
<section class="rounded-sm border border-line bg-surface p-5 shadow-sm"> <section class="rounded-sm border border-line bg-surface p-5 shadow-sm">
@@ -22,7 +25,7 @@ const visibleTags = $derived.by(() => tags.filter(hasAnyDocuments));
</h2> </h2>
<a <a
href="/themen" href="/themen"
class="font-sans text-xs text-brand-mint underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none" class="flex min-h-[44px] items-center text-[11px] font-semibold text-ink-2 no-underline focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
> >
{m.themen_alle()} {m.themen_alle()}
</a> </a>
@@ -35,9 +38,9 @@ const visibleTags = $derived.by(() => tags.filter(hasAnyDocuments));
class="grid gap-2 {compact ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2'}" class="grid gap-2 {compact ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2'}"
data-compact={compact} data-compact={compact}
> >
{#each visibleTags as tag (tag.id)} {#each shownTags as tag (tag.id)}
<a <a
href="/?tag={encodeURIComponent(tag.name)}" href="/documents?tag={encodeURIComponent(tag.name)}"
aria-label="{tag.name}{tag.documentCount > 0 aria-label="{tag.name}{tag.documentCount > 0
? ', ' + m.themen_dokumente({ count: tag.documentCount }) ? ', ' + m.themen_dokumente({ count: tag.documentCount })
: ''}" : ''}"

View File

@@ -59,37 +59,40 @@ const greetingText = $derived.by(() => {
<h1 class="font-serif text-[2rem] text-ink">{greetingText}</h1> <h1 class="font-serif text-[2rem] text-ink">{greetingText}</h1>
</div> </div>
{/if} {/if}
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start"> <div class="flex flex-col gap-5">
<div class="flex flex-col gap-5"> <DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
<EnrichmentBlock <ThemenWidget tags={data.tagTree ?? []} />
topDocs={data.incompleteDocs ?? []}
totalCount={data.incompleteTotal ?? 0}
bannerCount={bannerCount}
onBannerClose={() => (bannerCount = 0)}
/>
<section aria-label={m.dashboard_mission_caption()}> <div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"> <div class="flex flex-col gap-5">
{m.dashboard_mission_caption()} <EnrichmentBlock
</h2> topDocs={data.incompleteDocs ?? []}
<MissionControlStrip totalCount={data.incompleteTotal ?? 0}
segmentationDocs={data.segmentationDocs ?? []} bannerCount={bannerCount}
transcriptionDocs={data.transcriptionDocs ?? []} onBannerClose={() => (bannerCount = 0)}
readyDocs={data.readyDocs ?? []}
weeklyStats={data.weeklyStats ?? null}
/> />
</section>
</div>
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]"> <section aria-label={m.dashboard_mission_caption()}>
<DashboardFamilyPulse pulse={data.pulse ?? null} /> <h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
<ThemenWidget tags={data.tagTree ?? []} compact={true} /> {m.dashboard_mission_caption()}
<DashboardActivityFeed feed={data.activityFeed ?? []} /> </h2>
{#if data.canWrite} <MissionControlStrip
<DropZone onUploadComplete={(count) => (bannerCount = count)} /> segmentationDocs={data.segmentationDocs ?? []}
{/if} transcriptionDocs={data.transcriptionDocs ?? []}
readyDocs={data.readyDocs ?? []}
weeklyStats={data.weeklyStats ?? null}
/>
</section>
</div>
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
<DashboardFamilyPulse pulse={data.pulse ?? null} />
<DashboardActivityFeed feed={data.activityFeed ?? []} />
{#if data.canWrite}
<DropZone onUploadComplete={(count) => (bannerCount = count)} />
{/if}
</div>
</div> </div>
</div> </div>
{/if} {/if}

View File

@@ -421,7 +421,8 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
response: { ok: true }, response: { ok: true },
data: { items: [searchItem], totalElements: 1, pageNumber: 0, pageSize: 5, totalPages: 1 } data: { items: [searchItem], totalElements: 1, pageNumber: 0, pageSize: 5, totalPages: 1 }
}) // search }) // search
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // stories .mockResolvedValueOnce({ response: { ok: true }, data: [] }) // stories
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // tags/tree
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient typeof createApiClient
>); >);

View File

@@ -40,7 +40,7 @@ const visibleTree = $derived.by(() => data.tree.filter(hasAnyDocuments));
></div> ></div>
<a <a
href="/?tag={encodeURIComponent(tag.name)}" href="/documents?tag={encodeURIComponent(tag.name)}"
aria-label="{tag.name}{tag.documentCount > 0 aria-label="{tag.name}{tag.documentCount > 0
? ', ' + m.themen_dokumente({ count: tag.documentCount }) ? ', ' + m.themen_dokumente({ count: tag.documentCount })
: ''}" : ''}"
@@ -58,7 +58,7 @@ const visibleTree = $derived.by(() => data.tree.filter(hasAnyDocuments));
{#each shownChildren as child (child.id)} {#each shownChildren as child (child.id)}
<a <a
href="/?tag={encodeURIComponent(child.name)}" href="/documents?tag={encodeURIComponent(child.name)}"
class="flex min-h-[44px] items-center justify-between px-4 py-2.5 hover:bg-canvas focus-visible:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset" class="flex min-h-[44px] items-center justify-between px-4 py-2.5 hover:bg-canvas focus-visible:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"
> >
<span class="font-sans text-sm text-ink">{child.name}</span> <span class="font-sans text-sm text-ink">{child.name}</span>
@@ -71,7 +71,7 @@ const visibleTree = $derived.by(() => data.tree.filter(hasAnyDocuments));
{#if hiddenCount > 0} {#if hiddenCount > 0}
<a <a
href="/?tag={encodeURIComponent(tag.name)}" href="/documents?tag={encodeURIComponent(tag.name)}"
class="block min-h-[44px] px-4 py-2.5 font-sans text-sm text-ink-3 hover:bg-canvas hover:text-ink focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset" class="block min-h-[44px] px-4 py-2.5 font-sans text-sm text-ink-3 hover:bg-canvas hover:text-ink focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"
> >
{m.themen_weitere({ count: hiddenCount })} {m.themen_weitere({ count: hiddenCount })}