Compare commits

..

31 Commits

Author SHA1 Message Date
Marcel
b6466fcd95 fix(admin): wire delete-user button via enhance callback instead of requestSubmit()
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m46s
CI / OCR Service Tests (pull_request) Successful in 36s
CI / Backend Unit Tests (pull_request) Failing after 2m52s
CI / Unit & Component Tests (push) Failing after 2m51s
CI / OCR Service Tests (push) Successful in 40s
CI / Backend Unit Tests (push) Failing after 2m58s
The delete button used type=button + requestSubmit() to trigger the form,
which did not reliably fire SvelteKit's enhance submit listener. Replaced
with a type=submit button and an async enhance callback that guards with
the confirm dialog and calls cancel() on rejection.

Also clears the unsaved-changes dirty flag before the redirect so
beforeNavigate doesn't silently block the post-delete navigation.

Closes #277

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:52:24 +02:00
Marcel
e1d51728d9 refactor(audit): move AuditLogQueryService, AuditLogQueryRepository, and shared DTOs to audit package
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m48s
CI / OCR Service Tests (push) Successful in 48s
CI / Backend Unit Tests (push) Failing after 3m0s
TranscriptionQueueService was importing ActivityActorDTO and AuditLogQueryService
from the dashboard package, creating an inverted dependency (service → dashboard).
Moving these to the audit package where AuditLog lives gives both DashboardService
and TranscriptionQueueService the correct dependency direction (→ audit).

Moved to audit:
- ActivityActorDTO, ActivityFeedRow, ContributorRow, PulseStatsRow (projections)
- AuditLogQueryRepository, AuditLogQueryService

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
55ce696428 fix(dashboard): fix ContributorStack each-block key and add accessible avatar labels
- Replace (actor.name ?? actor.initials + i) with (actor.initials + '-' + actor.color)
  to fix operator-precedence bug that made keys order-dependent when name is null
- Add role="img" + aria-label={actor.name ?? actor.initials} so screen readers
  and touch users can access contributor names

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
12d92c78ea fix(layout): replace hardcoded 'Hochladen' with m.upload_action() + aria-label
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
d9157b99dd test(dashboard): fix stale resume mock — use totalBlocks instead of page/pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
3ede42503a fix(dashboard): i18n, a11y, security, and type-safety fixes from PR review
- Use @RequiredArgsConstructor in AuditLogQueryService; remove unused import
- Add 401/403 tests for /activity endpoint
- Add getPulseStats and findContributorsPerDocument integration tests
- Use m.pulse_headline/pulse_you in FamilyPulse; composite avatar keys
- Replace hover:text-accent with hover:text-ink in ActivityFeed (WCAG AA)
- Localise "Alle →" link with feed_show_all key + aria-label
- Gate DropZone behind {#if data.canWrite}
- Export DashboardResumeDTO, DashboardPulseDTO, ActivityFeedItemDTO from api.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
117044aad9 docs(spec): add /documents page design spec with mobile breakpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
eac025dec1 feat(dashboard): show block count instead of page numbers in resume strip
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
5147973379 refactor(dashboard): remove page field from DashboardResumeDTO; rename pages to totalBlocks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
3589e8659e fix(dashboard): bulk-load document titles in getActivity to avoid N+1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
bc762246e5 fix(dashboard): null-safe name join in toActorDTO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
7f6380452f fix(dashboard): include ANNOTATION_CREATED in hero resume query
findMostRecentDocumentIdByActor only matched TEXT_SAVED events, so documents
where the user drew annotation bounding boxes (but typed no transcription text)
were invisible to the hero resume card. Extending the IN clause to include
ANNOTATION_CREATED lets annotation-only work surface in the card (0% progress,
no excerpt — the correct state before transcription begins).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
267380f714 fix(audit): submit afterCommit write to executor to avoid transaction sync conflict
AuditService.logAfterCommit() called writeLog() inline inside the afterCommit()
callback. At that point Spring's transaction synchronizations are still active on
the thread, so SimpleJpaRepository.save() throws IllegalStateException which the
catch block silently swallowed — leaving audit_log permanently empty.

Fix: submit writeLog() to auditExecutor so it runs on a fresh thread with no active
synchronization context. Also switch auditExecutor from CallerRunsPolicy to AbortPolicy
to prevent the bug from silently recurring when the queue fills under load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
7506f8743a fix(dashboard): defensive null guard in ContributorStack; fix spec makeDoc factories 2026-04-20 07:45:16 +02:00
Marcel
520cca58b8 feat(dashboard): show contributor pill stack on each mission control queue item
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
4bd1ebfd1e feat(dashboard): add ContributorStack component for mission control pill stacks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
647a82b085 chore(types): regenerate API types with contributor fields on TranscriptionQueueItemDTO 2026-04-20 07:45:16 +02:00
Marcel
a3a9ad0471 test(dashboard): add empty-queue guard and boundary tests for contributor cap
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
812053cd6b feat(dashboard): add contributors to TranscriptionQueueItemDTO with 5-cap and hasMore flag
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
20cac8f6d9 feat(dashboard): expose findContributorsPerDocument in AuditLogQueryService 2026-04-20 07:45:16 +02:00
Marcel
935a8b16d2 fix(dashboard): use LEFT JOIN users in findContributorsPerDocument for deleted-user resilience 2026-04-20 07:45:16 +02:00
Marcel
24b203ac80 feat(dashboard): add findContributorsPerDocument query and ContributorRow projection 2026-04-20 07:45:16 +02:00
Marcel
5a98edac86 feat(dashboard): complete frontend redesign for Issue #271
- +layout.svelte: Upload button in header (authenticated users only)
- +page.server.ts: call /api/dashboard/resume, /pulse, /activity;
  remove deprecated /api/documents/incomplete and /recent-activity
- +page.svelte: 2-col grid layout (main + 320px sidebar), greeting,
  DashboardFamilyPulse + DashboardActivityFeed in sidebar
- DashboardResumeStrip: refactored to use server data (resumeDoc prop),
  SVG thumbnail, progress bar with aria-*, empty state, CTA
- DashboardFamilyPulse: new component — weekly stats from audit_log
- DashboardActivityFeed: new component — activity feed with "für dich" badge
- Update specs for new data shapes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
d34e8986af feat(i18n): add dashboard i18n keys (de/en/es)
Greeting, resume card, mission control, family pulse, activity feed,
audit action verbs, and dropzone keys for the Issue #271 dashboard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
06c75af96b chore(types): regenerate API types with dashboard endpoints
Adds DashboardResumeDTO, DashboardPulseDTO, ActivityFeedItemDTO,
ActivityActorDTO and the three /api/dashboard/* paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
ddd811c634 feat(dashboard): remove deprecated /incomplete and /recent-activity endpoints
GET /api/documents/incomplete and GET /api/documents/recent-activity are
superseded by the new dashboard endpoints (GET /api/dashboard/activity etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
250a00ff3c fix(migration): correct app_users → users table references in V46/V47
The AppUser entity is mapped to the 'users' table (not 'app_users').
V46 had a broken REFERENCES clause and hardcoded role in REVOKE; V47 and the
native query in AuditLogQueryRepository had the same wrong table name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
56a44bcef9 refactor(security): extract requireUserId to SecurityUtils
Both DocumentController and TranscriptionBlockController contained
identical private requireUserId helpers. Extracted to a shared static
utility in the security package ahead of DashboardController which
also needs actor resolution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
b3ae379be7 fix(audit): add blockId to TEXT_SAVED audit payload
Required for dashboard Pulse stat 2 (COUNT DISTINCT blockId).
Without it, two saves on different blocks on the same page
were indistinguishable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
74febd37f6 feat(user): add deterministic avatar color to AppUser
Adds color field assigned from an 8-colour palette keyed on the user's UUID
hash (Math.abs(id.hashCode()) % 8). Fires via @PrePersist/@PreUpdate/@PostLoad
so both new and existing users get the correct colour at runtime.

V47 migration adds the column and fixes the V46 REVOKE bug that hardcoded
role name 'app_user' instead of CURRENT_USER.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 07:45:16 +02:00
Marcel
6494b13147 docs(spec): add /documents page design spec with mobile breakpoints
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m32s
CI / OCR Service Tests (push) Successful in 33s
CI / Backend Unit Tests (push) Failing after 2m47s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 21:44:45 +02:00
50 changed files with 2253 additions and 204 deletions

View File

@@ -103,6 +103,11 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
<!-- Excel Bearbeitung (Apache POI) -->
<dependency>

View File

@@ -0,0 +1,10 @@
package org.raddatz.familienarchiv.audit;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.annotation.Nullable;
public record ActivityActorDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String initials,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String color,
@Nullable String name
) {}

View File

@@ -0,0 +1,15 @@
package org.raddatz.familienarchiv.audit;
import java.time.Instant;
import java.util.UUID;
public interface ActivityFeedRow {
String getKind();
UUID getActorId();
String getActorInitials();
String getActorColor();
String getActorName();
UUID getDocumentId();
Instant getHappenedAt();
boolean isYouMentioned();
}

View File

@@ -1,6 +1,5 @@
package org.raddatz.familienarchiv.dashboard;
package org.raddatz.familienarchiv.audit;
import org.raddatz.familienarchiv.audit.AuditLog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -15,7 +14,7 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
@Query(value = """
SELECT a.document_id
FROM audit_log a
WHERE a.kind = 'TEXT_SAVED'
WHERE a.kind IN ('TEXT_SAVED', 'ANNOTATION_CREATED')
AND a.actor_id = :userId
AND a.document_id IS NOT NULL
ORDER BY a.happened_at DESC
@@ -38,7 +37,7 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
COALESCE(u.color, '') AS actorColor,
CONCAT_WS(' ', u.first_name, u.last_name) AS actorName,
a.document_id AS documentId,
a.happened_at AS happenedAt,
a.happened_at AS happened_at,
(a.kind = 'MENTION_CREATED'
AND a.payload->>'mentionedUserId' = :currentUserId) AS youMentioned
FROM audit_log a
@@ -85,4 +84,26 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
List<Object[]> findMostRecentActorPerDocument(
@Param("documentIds") List<UUID> documentIds,
@Param("kind") String kind);
@Query(value = """
SELECT
a.document_id AS documentId,
CASE
WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL
THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1))
WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1))
WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1))
ELSE '?'
END AS actorInitials,
COALESCE(u.color, '') AS actorColor,
CONCAT_WS(' ', u.first_name, u.last_name) AS actorName
FROM audit_log a
LEFT JOIN users u ON u.id = a.actor_id
WHERE a.kind IN ('ANNOTATION_CREATED', 'TEXT_SAVED', 'BLOCK_REVIEWED')
AND a.document_id IN :documentIds
AND a.actor_id IS NOT NULL
GROUP BY a.document_id, a.actor_id, u.first_name, u.last_name, u.color
ORDER BY a.document_id, MIN(a.happened_at)
""", nativeQuery = true)
List<ContributorRow> findContributorsPerDocument(@Param("documentIds") List<UUID> documentIds);
}

View File

@@ -0,0 +1,49 @@
package org.raddatz.familienarchiv.audit;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.util.*;
@Service
@RequiredArgsConstructor
public class AuditLogQueryService {
private final AuditLogQueryRepository queryRepository;
public Optional<UUID> findMostRecentDocumentForUser(UUID userId) {
return queryRepository.findMostRecentDocumentIdByActor(userId);
}
public List<ActivityFeedRow> findActivityFeed(UUID currentUserId, int limit) {
return queryRepository.findDedupedActivityFeed(currentUserId.toString(), limit);
}
public PulseStatsRow getPulseStats(OffsetDateTime weekStart, UUID userId) {
return queryRepository.getPulseStats(weekStart, userId.toString());
}
public Map<UUID, UUID> findMostRecentActorPerDocument(List<UUID> documentIds, String kind) {
if (documentIds.isEmpty()) return Map.of();
List<Object[]> rows = queryRepository.findMostRecentActorPerDocument(documentIds, kind);
Map<UUID, UUID> result = new LinkedHashMap<>();
for (Object[] row : rows) {
UUID docId = (UUID) row[0];
UUID actorId = (UUID) row[1];
result.put(docId, actorId);
}
return result;
}
public Map<UUID, List<ActivityActorDTO>> findContributorsPerDocument(List<UUID> documentIds) {
if (documentIds.isEmpty()) return Map.of();
List<ContributorRow> rows = queryRepository.findContributorsPerDocument(documentIds);
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
for (ContributorRow row : rows) {
result.computeIfAbsent(row.getDocumentId(), k -> new ArrayList<>())
.add(new ActivityActorDTO(row.getActorInitials(), row.getActorColor(), row.getActorName()));
}
return result;
}
}

View File

@@ -2,6 +2,8 @@ package org.raddatz.familienarchiv.audit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
@@ -16,6 +18,8 @@ import java.util.UUID;
public class AuditService {
private final AuditLogRepository auditLogRepository;
@Qualifier("auditExecutor")
private final TaskExecutor auditExecutor;
@Async("auditExecutor")
public void log(AuditKind kind, UUID actorId, UUID documentId, Map<String, Object> payload) {
@@ -27,7 +31,10 @@ public class AuditService {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
writeLog(kind, actorId, documentId, payload);
// Run on a separate thread: the afterCommit() callback fires while Spring's
// transaction synchronizations are still active on the current thread, which
// prevents SimpleJpaRepository.save() from starting a new transaction inline.
auditExecutor.execute(() -> writeLog(kind, actorId, documentId, payload));
}
});
} else {

View File

@@ -0,0 +1,10 @@
package org.raddatz.familienarchiv.audit;
import java.util.UUID;
public interface ContributorRow {
UUID getDocumentId();
String getActorInitials();
String getActorColor();
String getActorName();
}

View File

@@ -0,0 +1,9 @@
package org.raddatz.familienarchiv.audit;
public interface PulseStatsRow {
long getPages();
long getAnnotated();
long getTranscribed();
long getUploaded();
long getYourPages();
}

View File

@@ -31,7 +31,10 @@ public class AsyncConfig {
executor.setMaxPoolSize(2);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("Audit-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// AbortPolicy instead of CallerRunsPolicy: if CallerRunsPolicy ran the task on the
// afterCommit() callback thread, Spring's transaction synchronizations would still be
// active on that thread and SimpleJpaRepository.save() would throw IllegalStateException.
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
return executor;
}
}

View File

@@ -0,0 +1,18 @@
package org.raddatz.familienarchiv.dashboard;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.annotation.Nullable;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.audit.AuditKind;
import java.time.OffsetDateTime;
import java.util.UUID;
public record ActivityFeedItemDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) AuditKind kind,
@Nullable ActivityActorDTO actor,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID documentId,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String documentTitle,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) OffsetDateTime happenedAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youMentioned
) {}

View File

@@ -0,0 +1,42 @@
package org.raddatz.familienarchiv.dashboard;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.security.SecurityUtils;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/dashboard")
@RequirePermission(Permission.READ_ALL)
@RequiredArgsConstructor
public class DashboardController {
private final DashboardService dashboardService;
private final UserService userService;
@GetMapping("/resume")
public DashboardResumeDTO getResume(Authentication authentication) {
UUID userId = SecurityUtils.requireUserId(authentication, userService);
return dashboardService.getResume(userId);
}
@GetMapping("/pulse")
public DashboardPulseDTO getPulse(Authentication authentication) {
UUID userId = SecurityUtils.requireUserId(authentication, userService);
return dashboardService.getPulse(userId);
}
@GetMapping("/activity")
public List<ActivityFeedItemDTO> getActivity(
Authentication authentication,
@RequestParam(defaultValue = "7") int limit) {
UUID userId = SecurityUtils.requireUserId(authentication, userService);
return dashboardService.getActivity(userId, Math.min(limit, 20));
}
}

View File

@@ -0,0 +1,15 @@
package org.raddatz.familienarchiv.dashboard;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import java.util.List;
public record DashboardPulseDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int pages,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int annotated,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int transcribed,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int uploaded,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int yourPages,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<ActivityActorDTO> contributors
) {}

View File

@@ -0,0 +1,19 @@
package org.raddatz.familienarchiv.dashboard;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.annotation.Nullable;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import java.util.List;
import java.util.UUID;
public record DashboardResumeDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID documentId,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String caption,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String excerpt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int totalBlocks,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int pct,
@Nullable String thumbnailUrl,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<ActivityActorDTO> collaborators
) {}

View File

@@ -0,0 +1,181 @@
package org.raddatz.familienarchiv.dashboard;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.audit.ActivityFeedRow;
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.audit.PulseStatsRow;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.TranscriptionService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.stereotype.Service;
import java.time.DayOfWeek;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.temporal.TemporalAdjusters;
import java.util.*;
import java.util.stream.Stream;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Slf4j
public class DashboardService {
private final AuditLogQueryService auditLogQueryService;
private final DocumentService documentService;
private final TranscriptionService transcriptionService;
private final UserService userService;
public DashboardResumeDTO getResume(UUID userId) {
Optional<UUID> docIdOpt = auditLogQueryService.findMostRecentDocumentForUser(userId);
if (docIdOpt.isEmpty()) return null;
UUID docId = docIdOpt.get();
Document doc;
try {
doc = documentService.getDocumentById(docId);
} catch (Exception e) {
log.warn("Resume: document {} not found for user {}", docId, userId);
return null;
}
List<TranscriptionBlock> blocks = transcriptionService.listBlocks(docId);
String excerpt = blocks.stream()
.filter(b -> b.getText() != null && !b.getText().isBlank())
.min(Comparator.comparingInt(TranscriptionBlock::getSortOrder))
.map(b -> b.getText().length() > 200 ? b.getText().substring(0, 200) + "" : b.getText())
.orElse("");
int totalBlocks = blocks.size();
long reviewedBlocks = blocks.stream().filter(TranscriptionBlock::isReviewed).count();
int pct = totalBlocks > 0 ? (int) (reviewedBlocks * 100L / totalBlocks) : 0;
String caption = buildCaption(doc);
List<UUID> collaboratorIds = blocks.stream()
.map(TranscriptionBlock::getUpdatedBy)
.filter(Objects::nonNull)
.distinct()
.limit(5)
.toList();
List<ActivityActorDTO> collaborators = collaboratorIds.stream()
.map(uid -> {
try {
AppUser u = userService.getById(uid);
return toActorDTO(u);
} catch (Exception e) {
return null;
}
})
.filter(Objects::nonNull)
.toList();
return new DashboardResumeDTO(docId, doc.getTitle(), caption, excerpt,
totalBlocks, pct, null, collaborators);
}
public DashboardPulseDTO getPulse(UUID userId) {
OffsetDateTime weekStart = OffsetDateTime.now(ZoneOffset.UTC)
.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
.withHour(0).withMinute(0).withSecond(0).withNano(0);
PulseStatsRow stats = auditLogQueryService.getPulseStats(weekStart, userId);
List<ActivityFeedRow> feed = auditLogQueryService.findActivityFeed(userId, 50);
List<ActivityActorDTO> contributors = feed.stream()
.filter(r -> r.getActorId() != null)
.map(r -> new ActivityActorDTO(r.getActorInitials(), r.getActorColor(), r.getActorName()))
.filter(a -> !a.initials().isBlank())
.distinct()
.limit(6)
.toList();
return new DashboardPulseDTO(
(int) stats.getPages(),
(int) stats.getAnnotated(),
(int) stats.getTranscribed(),
(int) stats.getUploaded(),
(int) stats.getYourPages(),
contributors
);
}
public List<ActivityFeedItemDTO> getActivity(UUID currentUserId, int limit) {
List<ActivityFeedRow> rows = auditLogQueryService.findActivityFeed(currentUserId, limit);
List<UUID> docIds = rows.stream()
.map(ActivityFeedRow::getDocumentId)
.filter(Objects::nonNull)
.distinct()
.toList();
Map<UUID, String> titleCache = new HashMap<>();
try {
documentService.getDocumentsByIds(docIds)
.forEach(d -> titleCache.put(d.getId(), d.getTitle()));
} catch (Exception e) {
log.warn("Activity: failed to bulk-load document titles", e);
}
return rows.stream().map(row -> {
ActivityActorDTO actor = row.getActorId() != null
? new ActivityActorDTO(row.getActorInitials(), row.getActorColor(), row.getActorName())
: null;
String docTitle = titleCache.getOrDefault(row.getDocumentId(), "");
return new ActivityFeedItemDTO(
org.raddatz.familienarchiv.audit.AuditKind.valueOf(row.getKind()),
actor,
row.getDocumentId(),
docTitle,
row.getHappenedAt().atOffset(ZoneOffset.UTC),
row.isYouMentioned()
);
}).toList();
}
private String buildCaption(Document doc) {
StringBuilder sb = new StringBuilder();
if (doc.getSender() != null) sb.append(personName(doc.getSender()));
if (!doc.getReceivers().isEmpty()) {
String receivers = doc.getReceivers().stream()
.map(this::personName).collect(Collectors.joining(", "));
if (!sb.isEmpty()) sb.append(" an ");
sb.append(receivers);
}
if (doc.getDocumentDate() != null) {
if (!sb.isEmpty()) sb.append(" · ");
sb.append(doc.getDocumentDate());
}
return sb.toString();
}
private String personName(Person p) {
if (p == null) return "";
if (p.getFirstName() != null && p.getLastName() != null) return p.getFirstName() + " " + p.getLastName();
if (p.getFirstName() != null) return p.getFirstName();
if (p.getLastName() != null) return p.getLastName();
return "";
}
private ActivityActorDTO toActorDTO(AppUser u) {
String initials = "";
if (u.getFirstName() != null && !u.getFirstName().isBlank())
initials += u.getFirstName().charAt(0);
if (u.getLastName() != null && !u.getLastName().isBlank())
initials += u.getLastName().charAt(0);
if (initials.isBlank() && u.getEmail() != null)
initials = u.getEmail().substring(0, 1).toUpperCase();
String fullName = Stream.of(u.getFirstName(), u.getLastName())
.filter(Objects::nonNull)
.collect(Collectors.joining(" "));
return new ActivityActorDTO(initials.toUpperCase(), u.getColor(), fullName);
}
}

View File

@@ -1,20 +1,19 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* A single row in one of the three Mission Control Strip queues.
* Annotation/block counts drive the per-document mini progress bar
* in the Transkription column and the percentage label in Lesefertig.
*/
public record TranscriptionQueueItemDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
LocalDate documentDate,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int annotationCount,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int textedBlockCount,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int reviewedBlockCount
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int reviewedBlockCount,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<ActivityActorDTO> contributors,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean hasMoreContributors
) {}

View File

@@ -484,6 +484,10 @@ public class DocumentService {
return doc;
}
public List<Document> getDocumentsByIds(List<UUID> ids) {
return documentRepository.findAllById(ids);
}
public List<Document> getDocumentsWithoutVersions() {
return documentRepository.findDocumentsWithoutVersions();
}

View File

@@ -1,6 +1,8 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO;
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
import org.raddatz.familienarchiv.repository.DocumentRepository;
@@ -8,38 +10,29 @@ import org.raddatz.familienarchiv.repository.TranscriptionQueueProjection;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Serves the three Mission Control Strip queues (Segmentierung / Transkription / Lesefertig)
* and the weekly activity pulse used by the column headers.
*/
@Service
@RequiredArgsConstructor
public class TranscriptionQueueService {
private static final int DEFAULT_QUEUE_SIZE = 5;
private static final int MAX_CONTRIBUTORS = 5;
private final DocumentRepository documentRepository;
private final AuditLogQueryService auditLogQueryService;
public List<TranscriptionQueueItemDTO> getSegmentationQueue() {
return documentRepository.findSegmentationQueue(DEFAULT_QUEUE_SIZE)
.stream()
.map(this::toDTO)
.toList();
return enrichWithContributors(documentRepository.findSegmentationQueue(DEFAULT_QUEUE_SIZE));
}
public List<TranscriptionQueueItemDTO> getTranscriptionQueue() {
return documentRepository.findTranscriptionQueue(DEFAULT_QUEUE_SIZE)
.stream()
.map(this::toDTO)
.toList();
return enrichWithContributors(documentRepository.findTranscriptionQueue(DEFAULT_QUEUE_SIZE));
}
public List<TranscriptionQueueItemDTO> getReadyToReadQueue() {
return documentRepository.findReadyToReadQueue(DEFAULT_QUEUE_SIZE)
.stream()
.map(this::toDTO)
.toList();
return enrichWithContributors(documentRepository.findReadyToReadQueue(DEFAULT_QUEUE_SIZE));
}
public TranscriptionWeeklyStatsDTO getWeeklyStats() {
@@ -50,14 +43,27 @@ public class TranscriptionQueueService {
);
}
private TranscriptionQueueItemDTO toDTO(TranscriptionQueueProjection p) {
private List<TranscriptionQueueItemDTO> enrichWithContributors(List<TranscriptionQueueProjection> projections) {
if (projections.isEmpty()) return List.of();
List<UUID> ids = projections.stream().map(TranscriptionQueueProjection::getId).toList();
Map<UUID, List<ActivityActorDTO>> contributorMap = auditLogQueryService.findContributorsPerDocument(ids);
return projections.stream()
.map(p -> toDTO(p, contributorMap.getOrDefault(p.getId(), List.of())))
.toList();
}
private TranscriptionQueueItemDTO toDTO(TranscriptionQueueProjection p, List<ActivityActorDTO> allContributors) {
boolean hasMore = allContributors.size() > MAX_CONTRIBUTORS;
List<ActivityActorDTO> capped = hasMore ? allContributors.subList(0, MAX_CONTRIBUTORS) : allContributors;
return new TranscriptionQueueItemDTO(
p.getId(),
p.getTitle(),
p.getDocumentDate(),
p.getAnnotationCount(),
p.getTextedBlockCount(),
p.getReviewedBlockCount()
p.getReviewedBlockCount(),
capped,
hasMore
);
}
}

View File

@@ -19,4 +19,7 @@ CREATE INDEX idx_audit_log_kind ON audit_log (kind);
-- Enforce append-only at the database layer: the application role may INSERT
-- but must not UPDATE or DELETE audit rows.
-- NOTE: This REVOKE is a no-op when the current user is the table owner.
-- PostgreSQL owners retain all privileges regardless of REVOKE. The append-only
-- guarantee is enforced at the application layer only.
REVOKE UPDATE, DELETE ON audit_log FROM CURRENT_USER;

View File

@@ -0,0 +1,53 @@
package org.raddatz.familienarchiv.audit;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.support.TransactionTemplate;
import software.amazon.awssdk.services.s3.S3Client;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class AuditServiceIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired AuditService auditService;
@Autowired AuditLogRepository auditLogRepository;
@Autowired TransactionTemplate transactionTemplate;
@Test
void logAfterCommit_writes_ANNOTATION_CREATED_row_after_transaction_commits() {
transactionTemplate.execute(status -> {
auditService.logAfterCommit(AuditKind.ANNOTATION_CREATED, null, null, null);
return null;
});
await().atMost(5, SECONDS).until(() -> auditLogRepository.count() > 0);
assertThat(auditLogRepository.findAll())
.extracting(AuditLog::getKind)
.containsExactly(AuditKind.ANNOTATION_CREATED);
}
@Test
void logAfterCommit_writes_no_row_when_transaction_rolls_back() {
try {
transactionTemplate.execute(status -> {
auditService.logAfterCommit(AuditKind.ANNOTATION_CREATED, null, null, null);
throw new RuntimeException("force rollback");
});
} catch (RuntimeException ignored) {}
assertThat(auditLogRepository.count()).isZero();
}
}

View File

@@ -7,6 +7,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.task.TaskExecutor;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@@ -24,6 +25,7 @@ import static org.mockito.Mockito.*;
class AuditServiceTest {
@Mock AuditLogRepository auditLogRepository;
@Mock TaskExecutor auditExecutor;
@InjectMocks AuditService auditService;
@Test
@@ -94,9 +96,7 @@ class AuditServiceTest {
}
@Test
void logAfterCommit_registersCallback_andSavesOnlyAfterCommit_whenTransactionIsActive() {
when(auditLogRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
void logAfterCommit_registersCallback_andSubmitsToExecutor_afterCommit() {
try (MockedStatic<TransactionSynchronizationManager> mocked =
mockStatic(TransactionSynchronizationManager.class)) {
mocked.when(TransactionSynchronizationManager::isActualTransactionActive).thenReturn(true);
@@ -106,15 +106,16 @@ class AuditServiceTest {
auditService.logAfterCommit(AuditKind.TEXT_SAVED, null, null, null);
// Callback registered but repo not yet called
// Callback registered but executor not yet invoked
assertThat(captured).hasSize(1);
verify(auditLogRepository, never()).save(any());
verify(auditExecutor, never()).execute(any());
// Simulate transaction commit
captured.get(0).afterCommit();
// Now the row should be saved
verify(auditLogRepository).save(any());
// Write submitted to executor — not called inline
verify(auditExecutor).execute(any());
verify(auditLogRepository, never()).save(any());
}
}
}

View File

@@ -17,6 +17,8 @@ import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@@ -41,7 +43,9 @@ class TranscriptionQueueControllerTest {
UUID.fromString("00000000-0000-0000-0000-000000000001"),
"Testbrief",
LocalDate.of(1920, 6, 15),
3, 1, 0
3, 1, 0,
List.of(new ActivityActorDTO("TR", "#a6dad8", "Test Raddatz")),
false
);
private static final TranscriptionWeeklyStatsDTO STATS = new TranscriptionWeeklyStatsDTO(2L, 5L);

View File

@@ -0,0 +1,94 @@
package org.raddatz.familienarchiv.dashboard;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.audit.ActivityFeedRow;
import org.raddatz.familienarchiv.audit.AuditLogQueryRepository;
import org.raddatz.familienarchiv.audit.ContributorRow;
import org.raddatz.familienarchiv.audit.PulseStatsRow;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.jdbc.Sql;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class AuditLogQueryRepositoryIntegrationTest {
static final UUID USER_ID = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
static final UUID DOC_ID = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
@Autowired AuditLogQueryRepository auditLogQueryRepository;
@Test
@Sql(statements = {
"INSERT INTO users (id, enabled, email, password) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', true, 'testuser@test.com', 'pw')",
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Doc', 'test.pdf', 'PLACEHOLDER')",
"INSERT INTO audit_log (kind, actor_id, document_id) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')"
})
void findMostRecentDocumentIdByActor_returns_document_with_annotation_only_events() {
Optional<UUID> result = auditLogQueryRepository.findMostRecentDocumentIdByActor(USER_ID);
assertThat(result).contains(DOC_ID);
}
@Test
@Sql(statements = {
"INSERT INTO users (id, enabled, email, password) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', true, 'testuser@test.com', 'pw')",
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Doc', 'test.pdf', 'PLACEHOLDER')",
"INSERT INTO audit_log (kind, actor_id, document_id, payload) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '{\"pageNumber\":1}')"
})
void findDedupedActivityFeed_returnsAnnotationEntry() {
List<ActivityFeedRow> rows = auditLogQueryRepository.findDedupedActivityFeed(USER_ID.toString(), 10);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getKind()).isEqualTo("ANNOTATION_CREATED");
assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_ID);
assertThat(rows.get(0).getHappenedAt()).isNotNull();
}
@Test
@Sql(statements = {
"INSERT INTO users (id, enabled, email, password) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', true, 'testuser@test.com', 'pw')",
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Doc', 'test.pdf', 'PLACEHOLDER')",
"INSERT INTO audit_log (kind, actor_id, document_id, payload) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '{\"pageNumber\":1}')",
"INSERT INTO audit_log (kind, actor_id, document_id, payload) VALUES ('TEXT_SAVED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '{\"blockId\":\"ccc\",\"pageNumber\":1}')",
"INSERT INTO audit_log (kind, document_id) VALUES ('FILE_UPLOADED', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')"
})
void getPulseStats_countsAnnotationsTranscriptionsAndUploads() {
OffsetDateTime weekStart = OffsetDateTime.now(ZoneOffset.UTC).minusDays(7);
PulseStatsRow stats = auditLogQueryRepository.getPulseStats(weekStart, USER_ID.toString());
assertThat(stats.getAnnotated()).isEqualTo(1);
assertThat(stats.getTranscribed()).isEqualTo(1);
assertThat(stats.getUploaded()).isEqualTo(1);
assertThat(stats.getYourPages()).isGreaterThanOrEqualTo(1);
}
@Test
@Sql(statements = {
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', true, 'testuser@test.com', 'pw', 'Anna', 'Meier', '#f00')",
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Doc', 'test.pdf', 'PLACEHOLDER')",
"INSERT INTO audit_log (kind, actor_id, document_id) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')"
})
void findContributorsPerDocument_returnsContributorWithInitialsAndColor() {
List<ContributorRow> rows = auditLogQueryRepository.findContributorsPerDocument(List.of(DOC_ID));
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_ID);
assertThat(rows.get(0).getActorInitials()).isEqualTo("AM");
assertThat(rows.get(0).getActorColor()).isEqualTo("#f00");
}
}

View File

@@ -0,0 +1,143 @@
package org.raddatz.familienarchiv.dashboard;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(DashboardController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class DashboardControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean DashboardService dashboardService;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
// ─── Security ────────────────────────────────────────────────────────────────
@Test
void resume_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/dashboard/resume"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void resume_returns403_whenUserHasNoPermissions() throws Exception {
mockMvc.perform(get("/api/dashboard/resume"))
.andExpect(status().isForbidden());
}
@Test
void pulse_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/dashboard/pulse"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void pulse_returns403_whenUserHasNoPermissions() throws Exception {
mockMvc.perform(get("/api/dashboard/pulse"))
.andExpect(status().isForbidden());
}
// ─── GET /api/dashboard/resume ────────────────────────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void resume_returnsNull_whenNoInProgressTranscription() throws Exception {
UUID userId = UUID.randomUUID();
when(userService.findByEmail(any())).thenReturn(
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
when(dashboardService.getResume(userId)).thenReturn(null);
mockMvc.perform(get("/api/dashboard/resume"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").doesNotExist());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void resume_returnsDocument_whenUserHasInProgressTranscription() throws Exception {
UUID userId = UUID.randomUUID();
UUID docId = UUID.randomUUID();
when(userService.findByEmail(any())).thenReturn(
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
DashboardResumeDTO resume = new DashboardResumeDTO(docId, "Brief an Frieda",
"Frieda an Wilhelm · 1923", "Liebe Frieda…", 4, 38, null, List.of());
when(dashboardService.getResume(userId)).thenReturn(resume);
mockMvc.perform(get("/api/dashboard/resume"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.documentId").value(docId.toString()))
.andExpect(jsonPath("$.pct").value(38));
}
// ─── GET /api/dashboard/pulse ─────────────────────────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void pulse_returnsWeekStats() throws Exception {
UUID userId = UUID.randomUUID();
when(userService.findByEmail(any())).thenReturn(
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
DashboardPulseDTO pulse = new DashboardPulseDTO(86, 23, 9, 47, 4, List.of());
when(dashboardService.getPulse(userId)).thenReturn(pulse);
mockMvc.perform(get("/api/dashboard/pulse"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.pages").value(86))
.andExpect(jsonPath("$.annotated").value(23));
}
@Test
void activity_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/dashboard/activity"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void activity_returns403_whenUserHasNoPermissions() throws Exception {
mockMvc.perform(get("/api/dashboard/activity"))
.andExpect(status().isForbidden());
}
// ─── GET /api/dashboard/activity ─────────────────────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void activity_returnsDeduplicated_feed() throws Exception {
UUID userId = UUID.randomUUID();
when(userService.findByEmail(any())).thenReturn(
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
when(dashboardService.getActivity(any(UUID.class), anyInt())).thenReturn(List.of());
mockMvc.perform(get("/api/dashboard/activity"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
}

View File

@@ -0,0 +1,109 @@
package org.raddatz.familienarchiv.dashboard;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.audit.ActivityFeedRow;
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.TranscriptionService;
import org.raddatz.familienarchiv.service.UserService;
import java.time.Instant;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DashboardServiceTest {
@Mock AuditLogQueryService auditLogQueryService;
@Mock DocumentService documentService;
@Mock TranscriptionService transcriptionService;
@Mock UserService userService;
@InjectMocks DashboardService dashboardService;
// ─── toActorDTO (via getResume collaborators) ─────────────────────────────
@Test
void getResume_collaboratorName_isNullSafe_whenFirstNameIsNull() {
UUID userId = UUID.randomUUID();
UUID docId = UUID.randomUUID();
UUID collaboratorId = UUID.randomUUID();
Document doc = Document.builder()
.id(docId).title("Brief").originalFilename("brief.pdf")
.receivers(new HashSet<>())
.build();
TranscriptionBlock block = TranscriptionBlock.builder()
.id(UUID.randomUUID()).annotationId(UUID.randomUUID())
.documentId(docId).sortOrder(1).updatedBy(collaboratorId)
.build();
AppUser collaborator = AppUser.builder()
.id(collaboratorId).email("s@test.com").password("pw")
.firstName(null).lastName("Schmidt").color("#abc")
.build();
when(auditLogQueryService.findMostRecentDocumentForUser(userId)).thenReturn(Optional.of(docId));
when(documentService.getDocumentById(docId)).thenReturn(doc);
when(transcriptionService.listBlocks(docId)).thenReturn(List.of(block));
when(userService.getById(collaboratorId)).thenReturn(collaborator);
DashboardResumeDTO result = dashboardService.getResume(userId);
assertThat(result).isNotNull();
assertThat(result.collaborators()).hasSize(1);
assertThat(result.collaborators().get(0).name()).isEqualTo("Schmidt");
}
// ─── getActivity bulk-load ────────────────────────────────────────────────
@Test
void getActivity_loadsDocumentTitles_withoutPerRowLookup() {
UUID userId = UUID.randomUUID();
UUID docId = UUID.randomUUID();
ActivityFeedRow row = mockFeedRow(docId, "ANNOTATION_CREATED");
when(auditLogQueryService.findActivityFeed(userId, 5)).thenReturn(List.of(row, row));
Document doc = Document.builder()
.id(docId).title("Familienbrief").originalFilename("f.pdf")
.receivers(new HashSet<>())
.build();
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(doc));
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5);
assertThat(items).hasSize(2);
assertThat(items.get(0).documentTitle()).isEqualTo("Familienbrief");
verify(documentService, never()).getDocumentById(docId);
}
private ActivityFeedRow mockFeedRow(UUID docId, String kind) {
return new ActivityFeedRow() {
public String getKind() { return kind; }
public UUID getActorId() { return null; }
public String getActorInitials() { return ""; }
public String getActorColor() { return ""; }
public String getActorName() { return ""; }
public UUID getDocumentId() { return docId; }
public Instant getHappenedAt() { return Instant.now(); }
public boolean isYouMentioned() { return false; }
};
}
}

View File

@@ -1,10 +1,13 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO;
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
import org.raddatz.familienarchiv.repository.DocumentRepository;
@@ -13,17 +16,25 @@ import org.raddatz.familienarchiv.repository.TranscriptionWeeklyStatsProjection;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class TranscriptionQueueServiceTest {
@Mock DocumentRepository documentRepository;
@Mock AuditLogQueryService auditLogQueryService;
@InjectMocks TranscriptionQueueService service;
@BeforeEach
void stubContributors() {
lenient().when(auditLogQueryService.findContributorsPerDocument(any())).thenReturn(Map.of());
}
// ─── getSegmentationQueue ─────────────────────────────────────────────────
@Test
@@ -42,6 +53,38 @@ class TranscriptionQueueServiceTest {
assertThat(result.get(0).annotationCount()).isEqualTo(0);
}
@Test
void getSegmentationQueue_returnsEmptyList_whenQueueIsEmpty() {
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of());
List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue();
assertThat(result).isEmpty();
verifyNoInteractions(auditLogQueryService);
}
@Test
void getSegmentationQueue_returnsAllFive_andHasMoreFalse_whenExactlyFiveContributors() {
UUID docId = UUID.randomUUID();
TranscriptionQueueProjection proj = mockQueueProjection(docId, "Brief", null, 0, 0, 0);
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of(proj));
List<ActivityActorDTO> fiveActors = List.of(
new ActivityActorDTO("A1", "#111", "Alice One"),
new ActivityActorDTO("A2", "#222", "Alice Two"),
new ActivityActorDTO("A3", "#333", "Alice Three"),
new ActivityActorDTO("A4", "#444", "Alice Four"),
new ActivityActorDTO("A5", "#555", "Alice Five")
);
when(auditLogQueryService.findContributorsPerDocument(List.of(docId)))
.thenReturn(Map.of(docId, fiveActors));
List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue();
assertThat(result.get(0).contributors()).hasSize(5);
assertThat(result.get(0).hasMoreContributors()).isFalse();
}
@Test
void getSegmentationQueue_mapsDocumentDateWhenPresent() {
LocalDate date = LocalDate.of(1920, 6, 15);
@@ -108,6 +151,47 @@ class TranscriptionQueueServiceTest {
assertThat(result.transcriptionCount()).isEqualTo(0L);
}
// ─── contributors ────────────────────────────────────────────────────────
@Test
void getSegmentationQueue_includesContributors_whenAuditDataPresent() {
UUID docId = UUID.randomUUID();
TranscriptionQueueProjection proj = mockQueueProjection(docId, "Brief", null, 0, 0, 0);
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of(proj));
ActivityActorDTO actor = new ActivityActorDTO("MR", "#a6dad8", "Max Raddatz");
when(auditLogQueryService.findContributorsPerDocument(List.of(docId)))
.thenReturn(Map.of(docId, List.of(actor)));
List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue();
assertThat(result.get(0).contributors()).containsExactly(actor);
assertThat(result.get(0).hasMoreContributors()).isFalse();
}
@Test
void getSegmentationQueue_capsContributorsAtFive_andSetsHasMoreFlag() {
UUID docId = UUID.randomUUID();
TranscriptionQueueProjection proj = mockQueueProjection(docId, "Brief", null, 0, 0, 0);
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of(proj));
List<ActivityActorDTO> sixActors = List.of(
new ActivityActorDTO("A1", "#111", "Alice One"),
new ActivityActorDTO("A2", "#222", "Alice Two"),
new ActivityActorDTO("A3", "#333", "Alice Three"),
new ActivityActorDTO("A4", "#444", "Alice Four"),
new ActivityActorDTO("A5", "#555", "Alice Five"),
new ActivityActorDTO("A6", "#666", "Alice Six")
);
when(auditLogQueryService.findContributorsPerDocument(List.of(docId)))
.thenReturn(Map.of(docId, sixActors));
List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue();
assertThat(result.get(0).contributors()).hasSize(5);
assertThat(result.get(0).hasMoreContributors()).isTrue();
}
// ─── helpers ─────────────────────────────────────────────────────────────
private TranscriptionQueueProjection mockQueueProjection(

View File

@@ -0,0 +1,665 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Dokumente-Seite — Design Spec</title>
<style>
:root{
--navy:#002850;--mint:#A6DAD8;--sand:#E4E2D7;
--surface:#FAFAF7;--bg:#E8E7E2;--border:#D8D7D0;
--text:#1C1C18;--muted:#6B6A63;--subtle:#9B9A93;
--orange:#C26A00;--orange-bg:#FEF4E2;
--green:#2E6E39;--green-bg:#EAF5EA;
--purple:#5B5EA6;--purple-bg:#EEEDFE;
--font:system-ui,sans-serif;--mono:'Courier New',monospace;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14px;line-height:1.6;}
.doc{max-width:1100px;margin:0 auto;padding:48px 32px 96px;}
hr{border:none;border-top:1px solid var(--border);margin:48px 0;}
/* Header */
.hdr{background:var(--navy);color:#fff;padding:32px 32px 28px;border-radius:8px 8px 0 0;}
.hdr h1{font-family:Georgia,serif;font-size:26px;font-weight:400;letter-spacing:-.02em;margin-bottom:8px;}
.hdr-meta{font-family:var(--mono);font-size:11px;color:rgba(255,255,255,.45);margin-top:10px;}
.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:4px;font-size:10px;font-weight:600;letter-spacing:.05em;background:var(--mint);color:var(--navy);}
.badge-g{background:rgba(255,255,255,.15);color:rgba(255,255,255,.9);}
.badges{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}
.decision-box{background:#fff;border:1px solid var(--border);border-top:none;border-radius:0 0 6px 6px;padding:20px 28px 24px;margin-bottom:40px;}
.decision-box h2{font-family:Georgia,serif;font-size:16px;font-weight:400;color:var(--navy);margin-bottom:8px;}
.prose{font-size:13px;color:var(--muted);line-height:1.65;max-width:720px;margin-bottom:10px;}
.prose:last-child{margin-bottom:0;}
/* Sections */
.sec{margin-bottom:52px;}
.sec-label{font-size:10px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);padding-bottom:8px;border-bottom:1px solid var(--border);margin-bottom:22px;}
.sec-title{font-family:Georgia,serif;font-size:20px;font-weight:400;color:var(--navy);margin-bottom:4px;}
.sec-sub{font-size:13px;color:var(--muted);margin-bottom:16px;}
/* Callout */
.callout{display:flex;gap:12px;padding:14px 16px;border-radius:4px;margin-bottom:16px;font-size:12px;line-height:1.55;}
.callout.orange{background:var(--orange-bg);border-left:3px solid var(--orange);}
.callout.green{background:var(--green-bg);border-left:3px solid var(--green);}
.callout.navy{background:rgba(0,40,80,.05);border-left:3px solid var(--navy);}
.callout.purple{background:var(--purple-bg);border-left:3px solid var(--purple);}
.callout strong{font-weight:700;}
.callout strong.o{color:var(--orange);}
.callout strong.g{color:var(--green);}
.callout strong.n{color:var(--navy);}
/* impl-ref */
.impl-ref{margin-top:20px;}
.impl-ref table{width:100%;border-collapse:collapse;font-size:12px;}
.impl-ref th{background:var(--navy);color:#fff;padding:6px 10px;text-align:left;font-size:10px;font-weight:600;letter-spacing:.06em;}
.impl-ref td{padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:top;line-height:1.6;}
.impl-ref tr:nth-child(even) td{background:var(--surface);}
.impl-ref code{font-family:var(--mono);font-size:11px;background:rgba(0,40,80,.06);padding:1px 4px;border-radius:2px;}
.impl-ref .new{color:var(--green);font-weight:600;}
.impl-ref .changed{color:var(--orange);font-weight:600;}
/* caption */
.caption{font-family:var(--mono);font-size:10px;color:var(--muted);display:block;margin-top:6px;}
/* Two-col layout */
.two-col{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:24px;}
.three-col{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-bottom:24px;}
/* ─── MOCKUP FRAME ───────────────────────────── */
/* Scaled at ~56% (640px wide renders as ~1140px concept) */
.frame-wrap{background:var(--surface);border:1px solid var(--border);border-radius:6px;overflow:hidden;box-shadow:0 4px 16px rgba(0,0,0,.08);margin-bottom:8px;}
/* Topbar */
.f-topbar{background:var(--navy);height:26px;display:flex;align-items:center;padding:0 14px;gap:12px;}
.f-logo{font-size:6.5px;font-weight:700;color:#fff;letter-spacing:.7px;}
.f-navlinks{display:flex;gap:8px;margin-left:6px;}
.f-navlink{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:600;text-transform:uppercase;letter-spacing:.05em;}
.f-navlink.on{color:rgba(255,255,255,.9);border-bottom:1.5px solid var(--mint);padding-bottom:1px;}
.f-navr{margin-left:auto;display:flex;align-items:center;gap:4px;}
.f-uname{font-size:5.5px;color:rgba(255,255,255,.4);text-transform:uppercase;font-weight:600;}
.f-av{width:14px;height:14px;border-radius:50%;background:var(--mint);display:flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:var(--navy);}
/* Search bar */
.f-searchbar{background:#fff;border-bottom:1px solid var(--sand);padding:7px 14px;display:flex;align-items:center;gap:8px;}
.f-search-input{flex:1;height:18px;border:1.5px solid var(--navy);border-radius:2px;padding:0 6px;display:flex;align-items:center;gap:4px;}
.f-search-q{font-size:6.5px;color:var(--navy);font-weight:600;}
.f-search-count{font-size:5.5px;font-weight:600;color:var(--subtle);text-transform:uppercase;letter-spacing:.06em;white-space:nowrap;}
.f-newbtn{height:18px;padding:0 7px;background:var(--navy);border-radius:2px;font-size:5.5px;font-weight:700;color:#fff;text-transform:uppercase;letter-spacing:.05em;display:flex;align-items:center;}
/* Sort bar */
.f-sortbar{background:#fff;border-bottom:1px solid var(--sand);padding:5px 14px;display:flex;align-items:center;gap:8px;}
.f-sortcount{flex:1;font-size:5.5px;color:var(--subtle);}
.f-sortlabel{font-size:5px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--subtle);}
.f-sortsel{height:14px;border:1px solid var(--sand);border-radius:2px;padding:0 5px;font-size:5.5px;color:var(--navy);background:var(--sand);display:flex;align-items:center;}
.f-filterbtn{height:14px;padding:0 6px;background:var(--navy);border-radius:2px;font-size:5px;font-weight:700;color:#fff;text-transform:uppercase;display:flex;align-items:center;gap:3px;}
.f-fbadge{background:var(--mint);color:var(--navy);font-size:4.5px;font-weight:800;border-radius:8px;padding:0 3px;}
/* Filter panel (open) */
.f-filterpanel{background:#fff;border-bottom:1px solid var(--sand);padding:8px 14px;display:flex;gap:16px;}
.f-fpgroup{flex:1;}
.f-fplabel{font-size:4.5px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:var(--subtle);margin-bottom:4px;display:block;}
.f-fpinput{height:12px;border:1px solid var(--sand);border-radius:2px;padding:0 5px;font-size:5px;color:var(--subtle);background:var(--sand);width:100%;display:flex;align-items:center;}
.f-fpdate{display:flex;gap:3px;}
.f-fpdate .f-fpinput{flex:1;}
.f-fptag{font-size:4.5px;font-weight:700;background:var(--mint);color:var(--navy);border-radius:2px;padding:1px 5px;display:inline-block;margin-right:2px;margin-bottom:2px;}
.f-fptag-e{font-size:4.5px;font-weight:700;background:var(--sand);color:var(--subtle);border-radius:2px;padding:1px 5px;display:inline-block;margin-right:2px;margin-bottom:2px;}
/* List body */
.f-body{padding:8px 14px;}
/* Year card */
.f-yearcard{border:1px solid var(--border);background:#fff;margin-bottom:8px;overflow:hidden;box-shadow:0 1px 2px rgba(0,0,0,.04);}
.f-yearhead{background:var(--sand);padding:3px 10px;font-size:5.5px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--subtle);border-bottom:1px solid var(--border);}
/* Document row */
.f-docrow{display:flex;border-bottom:1px solid #ece9e0;}
.f-docrow:last-child{border-bottom:none;}
.f-docrow:hover{background:#fafaf8;}
.f-docleft{flex:1;min-width:0;padding:8px 10px;border-right:1px solid #ece9e0;}
.f-docright{width:110px;flex-shrink:0;padding:6px 9px;display:flex;flex-direction:column;justify-content:space-between;}
.f-doctitle{font-family:Georgia,serif;font-size:7px;font-weight:700;color:var(--navy);margin-bottom:3px;line-height:1.3;}
.f-doctitle .hl{border-bottom:1.5px solid var(--navy);}
.f-docsnip{font-family:Georgia,serif;font-size:5.5px;color:#4b5563;font-style:italic;line-height:1.5;margin-bottom:4px;}
.f-docsnip .hl{border-bottom:1.5px solid var(--navy);}
.f-doctags{display:flex;gap:2px;flex-wrap:wrap;}
.f-doctag{font-size:4.5px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;background:#dbeafe;color:var(--navy);border-radius:1px;padding:1px 4px;display:flex;align-items:center;gap:2px;}
.f-doctag .dot{width:4px;height:4px;border-radius:50%;background:#3b82f6;flex-shrink:0;}
.f-doctag.fam{background:#dcfce7;}.f-doctag.fam .dot{background:#16a34a;}
.f-ml{display:flex;align-items:center;gap:3px;font-size:5px;color:var(--muted);margin-bottom:2px;}
.f-ml strong{font-size:4.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--navy);}
.f-meta-bottom{display:flex;align-items:center;justify-content:space-between;gap:4px;margin-top:4px;}
/* Ring */
.f-ring{position:relative;width:20px;height:20px;flex-shrink:0;}
.f-ring svg{position:absolute;top:0;left:0;transform:rotate(-90deg);}
.f-ring-label{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:4px;font-weight:800;}
/* Contributors */
.f-contribs{display:flex;}
.f-cav{width:12px;height:12px;border-radius:50%;border:1.5px solid white;display:flex;align-items:center;justify-content:center;font-size:4px;font-weight:800;color:#fff;margin-left:-3px;}
.f-contribs .f-cav:first-child{margin-left:0;}
/* annotation box */
.anno-box{background:#fff;border:1px solid var(--border);border-radius:6px;padding:16px 20px;margin-bottom:16px;}
.anno-box h4{font-size:12px;font-weight:700;color:var(--navy);margin-bottom:6px;}
.anno-box p{font-size:12px;color:var(--muted);line-height:1.55;margin-bottom:8px;}
.anno-box p:last-child{margin-bottom:0;}
.anno-box code{font-family:var(--mono);font-size:10px;background:rgba(0,40,80,.06);padding:1px 4px;border-radius:2px;}
</style>
</head>
<body>
<div class="doc">
<!-- ══════════════════════════════════════
HEADER
══════════════════════════════════════ -->
<div class="hdr">
<div class="badges">
<span class="badge">Neue Route</span>
<span class="badge badge-g">Frontend</span>
<span class="badge badge-g">Backend</span>
</div>
<h1>Dokumente-Seite — /documents</h1>
<p style="font-size:13px;color:rgba(255,255,255,.6);margin-top:6px;max-width:680px;">
Dedicated search and browse page for all documents. Separates the document list from the dashboard hub. Uses per-year group cards with flat divide-y rows, a horizontal split row (content left · metadata right), a circular progress ring, and contributor avatars.
</p>
<div class="hdr-meta">Spec · Leonie Voss · 2026-04-19 · Issue TBD</div>
</div>
<div class="decision-box">
<h2>Design decisions</h2>
<p class="prose">The hub (<code>/</code>) becomes pure dashboard — no more dual-mode switching. The "Documents" nav tab points to <code>/documents</code>, a focused search/browse page.</p>
<p class="prose">Row layout: two-column split — title and snippet occupy the full left column for maximum scan width; date, sender, receiver, archive location, progress ring and contributor avatars live in a fixed 240px right panel. This keeps metadata consistently positioned across all rows.</p>
<p class="prose">List structure: one white card container per year group (matching the current <code>border border-line bg-surface shadow-sm</code> pattern), rows separated by <code>divide-y</code> dividers — no gaps, no individual row cards. The year label is an inset header row within each card.</p>
<p class="prose">Progress ring shows work completion as a percentage (0100%). It is driven by a new <code>completionPercentage</code> field on the search result DTO, computed server-side from annotation block counts. Contributor avatars require a new <code>contributors</code> array (initials + color) on the search DTO.</p>
</div>
<hr/>
<!-- ══════════════════════════════════════
SECTION 1 — FULL MOCKUP
══════════════════════════════════════ -->
<div class="sec">
<div class="sec-label">Section 1</div>
<div class="sec-title">Full page mockup — filter panel open, search active</div>
<div class="sec-sub">Scaled at ~56%. Desktop 1200px concept width.</div>
<div class="frame-wrap">
<!-- Topbar -->
<div class="f-topbar">
<div class="f-logo">FAMILIENARCHIV</div>
<div class="f-navlinks">
<span class="f-navlink on">Documents</span>
<span class="f-navlink">Persons</span>
<span class="f-navlink">Letters</span>
<span class="f-navlink">Admin</span>
</div>
<div class="f-navr"><span class="f-uname">Hochlader</span><div class="f-av">MR</div></div>
</div>
<!-- Search bar -->
<div class="f-searchbar">
<div class="f-search-input">
<svg width="8" height="8" viewBox="0 0 20 20" fill="none"><circle cx="8.5" cy="8.5" r="5.75" stroke="#9ca3af" stroke-width="1.5"/><path d="M13 13l3.5 3.5" stroke="#9ca3af" stroke-width="1.5" stroke-linecap="round"/></svg>
<span class="f-search-q">brief</span>
</div>
<span class="f-search-count">31 Dokumente</span>
<div class="f-newbtn">+ New Document</div>
</div>
<!-- Sort bar -->
<div class="f-sortbar">
<span class="f-sortcount">31 documents</span>
<span class="f-sortlabel">Sort</span>
<div class="f-sortsel">Date ↓</div>
<div class="f-filterbtn">
<svg width="7" height="7" viewBox="0 0 16 16" fill="none"><path d="M2 4h12M4 8h8M6 12h4" stroke="white" stroke-width="1.5" stroke-linecap="round"/></svg>
Filters <span class="f-fbadge">1</span>
</div>
</div>
<!-- Filter panel -->
<div class="f-filterpanel">
<div class="f-fpgroup">
<span class="f-fplabel">Date range</span>
<div class="f-fpdate"><div class="f-fpinput">From</div><div class="f-fpinput">To</div></div>
</div>
<div class="f-fpgroup">
<span class="f-fplabel">Sender</span>
<div class="f-fpinput">Search person…</div>
</div>
<div class="f-fpgroup">
<span class="f-fplabel">Receiver</span>
<div class="f-fpinput">Search person…</div>
</div>
<div class="f-fpgroup">
<span class="f-fplabel">Tags</span>
<span class="f-fptag">Brief</span><span class="f-fptag-e">Foto</span><span class="f-fptag-e">Postkarte</span><span class="f-fptag-e">Urkunde</span>
</div>
</div>
<!-- List body -->
<div class="f-body">
<!-- 1924 card -->
<div class="f-yearcard">
<div class="f-yearhead">1924</div>
<div class="f-docrow">
<div class="f-docleft">
<div class="f-doctitle">Demo: Ierlicher <span class="hl">Brief</span> — Belgern</div>
<div class="f-docsnip">… Hiermit übersende ich Ihnen den gewünschten <span class="hl">Brief</span> meines Vaters, welcher einige interessante Hinweise zur Familiengeschichte enthält …</div>
<div class="f-doctags"><div class="f-doctag"><div class="dot"></div>Brief</div><div class="f-doctag fam"><div class="dot"></div>Familie</div></div>
</div>
<div class="f-docright">
<div>
<div class="f-ml"><strong>Date</strong> 31. Mai 1924</div>
<div class="f-ml"><strong>From</strong> Louise Aon Boden</div>
<div class="f-ml"><strong>To</strong> Marcel Raddatz</div>
<div class="f-ml"><strong>Archive</strong> Box 3 · Folder A</div>
</div>
<div class="f-meta-bottom">
<div class="f-ring">
<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7" fill="none" stroke="#E4E2D7" stroke-width="2"/><circle cx="10" cy="10" r="7" fill="none" stroke="#A6DAD8" stroke-width="2" stroke-dasharray="44 44" stroke-linecap="round"/></svg>
<div class="f-ring-label" style="color:#5bbab7">100%</div>
</div>
<div class="f-contribs"><div class="f-cav" style="background:#7c3aed">MR</div><div class="f-cav" style="background:#0891b2">LS</div></div>
</div>
</div>
</div>
</div>
<!-- 1923 card -->
<div class="f-yearcard">
<div class="f-yearhead">1923</div>
<div class="f-docrow">
<div class="f-docleft">
<div class="f-doctitle">W-0614 8. September 1923 Tölz</div>
<div class="f-docsnip">… Clara schreibt über die Ankunft in Tölz und erwähnt den letzten <span class="hl">Brief</span> von Fauld Rupley, der noch keine Antwort erhalten hat …</div>
<div class="f-doctags"><div class="f-doctag"><div class="dot"></div>Brief</div></div>
</div>
<div class="f-docright">
<div>
<div class="f-ml"><strong>Date</strong> 8. Sept. 1923</div>
<div class="f-ml"><strong>From</strong> Clara Lam</div>
<div class="f-ml"><strong>To</strong> Fauld Rupley</div>
<div class="f-ml"><strong>Archive</strong> Box 1 · Folder C</div>
</div>
<div class="f-meta-bottom">
<div class="f-ring">
<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7" fill="none" stroke="#E4E2D7" stroke-width="2"/><circle cx="10" cy="10" r="7" fill="none" stroke="#A6DAD8" stroke-width="2" stroke-dasharray="33 44" stroke-linecap="round"/></svg>
<div class="f-ring-label" style="color:#5bbab7">75%</div>
</div>
<div class="f-contribs"><div class="f-cav" style="background:#dc2626">AK</div></div>
</div>
</div>
</div>
<div class="f-docrow">
<div class="f-docleft">
<div class="f-doctitle">W-0196 2. September 1923 B. Lichterfelde</div>
<div class="f-docsnip">… Prediger's Haushaltung enthält einen <span class="hl">Brief</span>; Zusammen mit der Vollmacht aus dem Vorjahr ergibt sich folgendes Bild …</div>
<div class="f-doctags"><div class="f-doctag"><div class="dot"></div>Brief</div></div>
</div>
<div class="f-docright">
<div>
<div class="f-ml"><strong>Date</strong> 2. Sept. 1923</div>
<div class="f-ml"><strong>From</strong> Müller de Gruym</div>
<div class="f-ml"><strong>To</strong> Herbert Cram</div>
</div>
<div class="f-meta-bottom">
<div class="f-ring">
<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7" fill="none" stroke="#E4E2D7" stroke-width="2"/><circle cx="10" cy="10" r="7" fill="none" stroke="#A6DAD8" stroke-width="2" stroke-dasharray="18 44" stroke-linecap="round"/></svg>
<div class="f-ring-label" style="color:#9ca3af">40%</div>
</div>
<div class="f-contribs"><div class="f-cav" style="background:#7c3aed">MR</div><div class="f-cav" style="background:#0891b2">LS</div><div class="f-cav" style="background:#dc2626">AK</div></div>
</div>
</div>
</div>
<div class="f-docrow">
<div class="f-docleft">
<div class="f-doctitle">W-0397 2. September 1923 B. Lichterfelde</div>
<div class="f-docsnip">… zum einleitend Kommentar hieraus, den Herrn, zum <span class="hl">Brief</span> az sechzig und weitere Passagen …</div>
<div class="f-doctags"><div class="f-doctag"><div class="dot"></div>Brief</div></div>
</div>
<div class="f-docright">
<div>
<div class="f-ml"><strong>Date</strong> 2. Sept. 1923</div>
<div class="f-ml"><strong>From</strong> Müller de Gruym</div>
</div>
<div class="f-meta-bottom">
<div class="f-ring">
<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7" fill="none" stroke="#E4E2D7" stroke-width="2"/></svg>
<div class="f-ring-label" style="color:#9ca3af">0%</div>
</div>
<span style="font-size:4.5px;color:#9ca3af;font-weight:600;text-transform:uppercase;letter-spacing:.08em;">No contributors</span>
</div>
</div>
</div>
</div>
</div>
</div>
<span class="caption">Fig 1 — /documents · 1200px · search: "brief" · filter panel open · sort: Date ↓</span>
</div>
<hr/>
<!-- ══════════════════════════════════════
SECTION 2 — PAGE STRUCTURE
══════════════════════════════════════ -->
<div class="sec">
<div class="sec-label">Section 2</div>
<div class="sec-title">Page structure & zones</div>
<div class="three-col">
<div class="anno-box">
<h4>① Global search bar</h4>
<p>Full-width row below the topbar. Contains the search input (flex-1), result count (right of input), and "+ New Document" button. Background white, bottom border <code>border-line</code>. Sticky — stays visible on scroll.</p>
<p>Same search bar pattern as the current homepage. Debounce 500 ms on text input; immediate on clear.</p>
</div>
<div class="anno-box">
<h4>② Sort / count bar</h4>
<p>Slim bar below search. Shows result count (left), sort dropdown (right), and Filters toggle button (far right). Background white, bottom border <code>border-line</code>. Sticky — stacks below search bar on scroll.</p>
<p>Filters button shows a mint badge with active filter count. When filters are open the button fills navy.</p>
</div>
<div class="anno-box">
<h4>③ Collapsible filter panel</h4>
<p>Drops open below the sort bar. Contains four groups: Date range (two inputs), Sender (PersonTypeahead), Receiver (PersonTypeahead), Tags (clickable pills). White background, bottom border <code>border-line</code>.</p>
<p>Closed by default on page load unless URL already has active filter params. Animate open/close with <code>transition-all duration-200</code>.</p>
</div>
</div>
<div class="callout navy">
<div><strong class="n">Routing:</strong> New route <code>frontend/src/routes/documents/+page.svelte</code> and <code>+page.server.ts</code>. AppNav "Documents" tab <code>href</code> changes from <code>/</code> to <code>/documents</code>. Homepage <code>+page.svelte</code> loses dual-mode — always renders dashboard. No redirect from <code>/?q=…</code>.</div>
</div>
</div>
<hr/>
<!-- ══════════════════════════════════════
SECTION 2b — MOBILE BREAKPOINTS
══════════════════════════════════════ -->
<div class="sec">
<div class="sec-label">Section 2b</div>
<div class="sec-title">Mobile breakpoints</div>
<div class="sec-sub">Three responsive tiers: &lt;sm (mobile), smlg (tablet), lg+ (desktop).</div>
<div class="three-col">
<div class="anno-box">
<h4>&lt; sm — &lt; 640px (mobile)</h4>
<p>Document row: single-column block. Left/right split collapses. Metadata (date, from, to, archive) moves below the tags row as a 2×2 compact grid. Progress ring and contributor stack appear in a bottom row directly below the grid.</p>
<p>Filter panel: single-column stack (<code>flex-col</code>). Sort bar wraps if needed.</p>
</div>
<div class="anno-box">
<h4>sm lg — 6401023px</h4>
<p>Document row: two-column split restored. Metadata column narrower: <code>sm:w-48</code> (192px) instead of <code>w-60</code> to fit tablet viewports.</p>
<p>Sticky bars span full width via negative margins. Filter panel: <code>flex-row flex-wrap</code>, groups can wrap.</p>
</div>
<div class="anno-box">
<h4>lg+ — ≥ 1024px (desktop)</h4>
<p>Full two-column split. Metadata column: <code>lg:w-60</code> (240px). Filter panel: four groups in a single row. Max content width <code>max-w-7xl</code> (1280px) — from app layout container, no extra padding on list body.</p>
</div>
</div>
<!-- Mobile mockup at ~375px concept width, rendered at ~220px -->
<div class="two-col">
<div>
<div class="frame-wrap" style="width:220px;">
<div class="f-topbar" style="height:20px;padding:0 8px;gap:6px;">
<span class="f-logo" style="font-size:5px;">FAMILIENARCHIV</span>
<div style="margin-left:auto;display:flex;gap:6px;align-items:center;">
<span class="f-navlink on" style="font-size:4.5px;border-bottom-width:1px;">Dokumente</span>
<div class="f-av" style="width:12px;height:12px;font-size:4px;">MR</div>
</div>
</div>
<div class="f-searchbar" style="padding:5px 8px;gap:5px;">
<div class="f-search-input" style="height:14px;padding:0 5px;">
<svg width="6" height="6" viewBox="0 0 20 20" fill="none"><circle cx="8.5" cy="8.5" r="5.75" stroke="#9ca3af" stroke-width="1.5"/><path d="M13 13l3.5 3.5" stroke="#9ca3af" stroke-width="1.5" stroke-linecap="round"/></svg>
<span style="font-size:5px;color:#002850;font-weight:600;">brief</span>
</div>
<span style="font-size:4.5px;color:#9B9A93;white-space:nowrap;font-weight:700;text-transform:uppercase;letter-spacing:.06em;">31 Dok.</span>
</div>
<div class="f-sortbar" style="padding:3px 8px;gap:5px;">
<span class="f-sortcount">31 documents</span>
<span class="f-sortlabel">Sort</span>
<div class="f-sortsel" style="height:11px;font-size:4.5px;padding:0 4px;">Date ↓</div>
<div class="f-filterbtn" style="height:11px;font-size:4px;padding:0 4px;">Filters</div>
</div>
<div style="padding:5px 8px;">
<div class="f-yearcard">
<div class="f-yearhead">1924</div>
<!-- mobile stacked row 1 -->
<div style="padding:7px 8px;border-bottom:1px solid #ece9e0;">
<div class="f-doctitle" style="margin-bottom:2px;">Demo: Ierlicher <span class="hl">Brief</span> — Belgern</div>
<div class="f-docsnip" style="margin-bottom:3px;">… Hiermit übersende ich Ihnen den gewünschten <span class="hl">Brief</span></div>
<div class="f-doctags" style="margin-bottom:0;"><div class="f-doctag"><div class="dot"></div>Brief</div></div>
<div style="border-top:1px solid #ece9e0;margin-top:4px;padding-top:3px;display:grid;grid-template-columns:1fr 1fr;gap:0 8px;">
<div class="f-ml"><strong>Date</strong> 31. Mai 1924</div>
<div class="f-ml"><strong>From</strong> L. von Boden</div>
<div class="f-ml"><strong>Archive</strong> Box 3 · A</div>
<div class="f-ml"><strong>To</strong> M. Raddatz</div>
</div>
<div class="f-meta-bottom" style="margin-top:4px;">
<div class="f-ring">
<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7" fill="none" stroke="#E4E2D7" stroke-width="2"/><circle cx="10" cy="10" r="7" fill="none" stroke="#A6DAD8" stroke-width="2" stroke-dasharray="44 44" stroke-linecap="round"/></svg>
<div class="f-ring-label" style="color:#5bbab7">100%</div>
</div>
<div class="f-contribs"><div class="f-cav" style="background:#7c3aed">MR</div><div class="f-cav" style="background:#0891b2">LS</div></div>
</div>
</div>
<!-- mobile stacked row 2 -->
<div style="padding:7px 8px;">
<div class="f-doctitle" style="margin-bottom:2px;">W-0614 Sept. 1923 Tölz</div>
<div class="f-docsnip" style="margin-bottom:3px;">… Clara schreibt über den letzten <span class="hl">Brief</span> von Fauld Rupley …</div>
<div class="f-doctags" style="margin-bottom:0;"><div class="f-doctag"><div class="dot"></div>Brief</div></div>
<div style="border-top:1px solid #ece9e0;margin-top:4px;padding-top:3px;display:grid;grid-template-columns:1fr 1fr;gap:0 8px;">
<div class="f-ml"><strong>Date</strong> 8. Sept. 1923</div>
<div class="f-ml"><strong>From</strong> Clara Lam</div>
<div class="f-ml"></div>
<div class="f-ml"><strong>To</strong> F. Rupley</div>
</div>
<div class="f-meta-bottom" style="margin-top:4px;">
<div class="f-ring">
<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7" fill="none" stroke="#E4E2D7" stroke-width="2"/><circle cx="10" cy="10" r="7" fill="none" stroke="#A6DAD8" stroke-width="2" stroke-dasharray="33 44" stroke-linecap="round"/></svg>
<div class="f-ring-label" style="color:#5bbab7">75%</div>
</div>
<div class="f-contribs"><div class="f-cav" style="background:#dc2626">AK</div></div>
</div>
</div>
</div>
</div>
</div>
<span class="caption">Fig 2 — /documents · 375px mobile · search "brief" · filter closed</span>
</div>
<div class="anno-box" style="align-self:start">
<h4>Mobile row — CSS-only approach</h4>
<p>No JS needed. The <code>&lt;a&gt;</code> link is always <code>block</code>. On <code>sm+</code> the inner element switches to <code>flex items-stretch</code>, showing the right metadata column (<code>hidden sm:flex</code>) and hiding the mobile compact grid (<code>sm:hidden</code>).</p>
<p>This means the DOM contains both layouts simultaneously — the metadata grid inside the left column (mobile only) and the right metadata panel (sm+ only). Both share the same data, just rendered differently.</p>
<p>Minimum touch target: the entire row is the <code>&lt;a&gt;</code>, guaranteed ≥44px on mobile given title + snippet + tags + metadata grid.</p>
</div>
</div>
</div>
<hr/>
<!-- ══════════════════════════════════════
SECTION 3 — YEAR GROUP CARD
══════════════════════════════════════ -->
<div class="sec">
<div class="sec-label">Section 3</div>
<div class="sec-title">Year group card</div>
<div class="sec-sub">One card per year group. Rows inside use divide-y — no gaps between rows.</div>
<div class="two-col">
<div class="anno-box">
<h4>Card container</h4>
<p>Matches current DocumentList outer container exactly: <code>border border-line bg-surface shadow-sm</code>. No border-radius (keeps it flush). Margin between consecutive year cards: <code>mb-4</code>.</p>
<p>Rendered only when sort = DATE. For other sort modes (SENDER, RECEIVER, TITLE) the year header is replaced by the relevant group label using the same card pattern.</p>
</div>
<div class="anno-box">
<h4>Year header row</h4>
<p>First child of each card. Background <code>bg-sand</code>, text <code>text-xs font-bold uppercase tracking-widest text-ink-3</code>. Height <code>py-1.5 px-5</code>. Bottom border <code>border-b border-line</code>.</p>
<p>Not a standalone divider — it is part of the card so the top border of the card frames the year label on three sides.</p>
</div>
</div>
</div>
<hr/>
<!-- ══════════════════════════════════════
SECTION 4 — DOCUMENT ROW
══════════════════════════════════════ -->
<div class="sec">
<div class="sec-label">Section 4</div>
<div class="sec-title">Document row — two-column split</div>
<div class="two-col">
<div class="anno-box">
<h4>Left column — content</h4>
<p>Flex-1, min-width 0. Padding <code>p-4 pr-5</code>. Right border <code>border-r border-line-2</code>.</p>
<p><strong>Title</strong><code>font-serif text-base font-bold text-ink</code> with search highlight underlines. <code>mb-1.5</code>.</p>
<p><strong>Snippet</strong><code>font-serif text-sm italic text-ink-2 line-clamp-2 mb-2</code> with highlight underlines. Only rendered when a match snippet is present.</p>
<p><strong>Tags</strong> — existing tag pill pattern <code>bg-muted text-ink text-[10px] font-bold uppercase tracking-widest rounded px-2 py-0.5</code>. Gap <code>gap-1.5 flex-wrap</code>.</p>
</div>
<div class="anno-box">
<h4>Right column — metadata panel</h4>
<p>Fixed width <code>w-60</code> (240px). Padding <code>p-3.5</code>. Flex column, <code>justify-between</code>.</p>
<p><strong>Meta lines</strong> (top group) — <code>font-sans text-[11px] text-ink-2 mb-1</code>. Label: <code>font-bold uppercase tracking-wide text-[10px] text-ink-3 mr-1.5</code>. Lines: Date · From · To · Archive (Box · Folder). Archive only rendered when <code>archiveBox</code> is set.</p>
<p><strong>Bottom row</strong> — flexbox, space-between. Left: progress ring. Right: ContributorStack.</p>
</div>
</div>
<div class="callout orange">
<div><strong class="o">Accessibility:</strong> The ring conveys progress by both percentage text and arc fill — not colour alone. Contributors show initials as text inside the avatar. Both pass the redundant-cue requirement from the Leonie Voss persona. Minimum touch target for the row link: the full row is the <code>&lt;a&gt;</code> element, always ≥44px tall given the content. Row hover: <code>hover:bg-muted/50 transition-colors duration-200</code>.</div>
</div>
</div>
<hr/>
<!-- ══════════════════════════════════════
SECTION 5 — PROGRESS RING
══════════════════════════════════════ -->
<div class="sec">
<div class="sec-label">Section 5</div>
<div class="sec-title">Progress ring</div>
<div class="two-col">
<div class="anno-box">
<h4>Anatomy</h4>
<p>SVG donut ring, 36×36px. Track circle: <code>stroke="#E4E2D7"</code> (<code>stroke-brand-sand</code>) width 3px. Fill arc: <code>stroke="#A6DAD8"</code> (<code>stroke-accent</code>) width 3px, <code>stroke-linecap="round"</code>. Rotated 90° so arc starts at 12 o'clock.</p>
<p>Centre label: percentage text <code>font-sans text-[8px] font-bold</code>. Colour: mint (<code>text-accent-dark</code>) when &gt;0%, gray-400 when 0%.</p>
<p>Circumference of r=13: <code>×13 ≈ 81.7px</code>. Stroke-dasharray: <code>{pct * 81.7} 81.7</code>.</p>
</div>
<div class="anno-box">
<h4>Data source — new API field</h4>
<p>New field <code>completionPercentage: number</code> (0100, integer) on the document search result DTO. Computed server-side:</p>
<p><code>round((reviewedBlocks / max(totalBlocks, 1)) * 100)</code></p>
<p>If a document has no annotation blocks yet (no transcription started), returns 0. Backend change: new subquery in the document search repository to COUNT annotation blocks (all vs. reviewed) per document, joined into the search projection.</p>
</div>
</div>
</div>
<hr/>
<!-- ══════════════════════════════════════
SECTION 6 — CONTRIBUTOR AVATARS
══════════════════════════════════════ -->
<div class="sec">
<div class="sec-label">Section 6</div>
<div class="sec-title">Contributor avatar stack</div>
<div class="two-col">
<div class="anno-box">
<h4>Anatomy</h4>
<p>Reuse existing <code>ContributorStack.svelte</code> component (added in commit 031f6ea). Avatars 22×22px, <code>-ml-1.5</code> overlap, white 2px border.</p>
<p>Show max 3 avatars. If more: <code>+N</code> text element in gray-400. When no contributors: render <code>text-[9px] text-ink-3 uppercase tracking-wide</code> label "No contributors".</p>
</div>
<div class="anno-box">
<h4>Data source — new API field</h4>
<p>New field <code>contributors: ActivityActorDTO[]</code> on the document search result DTO. <code>ActivityActorDTO</code> already exists (used in dashboard queue items): <code>{ initials: string, color: string, name?: string }</code>.</p>
<p>Backend: join from document → annotation_blocks → created_by → users. Distinct by user. Order by most-recent contribution. Limit 4. New query in document search repository.</p>
</div>
</div>
</div>
<hr/>
<!-- ══════════════════════════════════════
SECTION 7 — BACKEND CHANGES
══════════════════════════════════════ -->
<div class="sec">
<div class="sec-label">Section 7</div>
<div class="sec-title">Backend changes required</div>
<div class="callout green">
<div><strong class="g">New fields on document search DTO</strong> — Two new fields must be added to the object returned by <code>GET /api/documents/search</code>. These require a new projection or join in the repository layer. No schema migration needed — purely computed from existing annotation_block data.</div>
</div>
<div class="impl-ref">
<table>
<thead><tr><th>Field</th><th>Type</th><th>Source</th><th>Notes</th></tr></thead>
<tbody>
<tr><td><code>completionPercentage</code></td><td><code>int</code> (0100)</td><td>COUNT(reviewed annotation blocks) / COUNT(all blocks)</td><td>0 when no blocks exist</td></tr>
<tr><td><code>contributors</code></td><td><code>ActivityActorDTO[]</code></td><td>Distinct users with annotation_block contributions, ordered by recency</td><td>Max 4; reuse existing DTO</td></tr>
<tr><td><code>archiveBox</code></td><td><code>String?</code></td><td>Already on Document entity — just not in search response</td><td><span class="changed">Expose existing field</span></td></tr>
<tr><td><code>archiveFolder</code></td><td><code>String?</code></td><td>Already on Document entity — just not in search response</td><td><span class="changed">Expose existing field</span></td></tr>
</tbody>
</table>
</div>
</div>
<hr/>
<!-- ══════════════════════════════════════
SECTION 8 — IMPL-REF TABLE
══════════════════════════════════════ -->
<div class="sec">
<div class="sec-label">Section 8 — Implementation Reference</div>
<div class="sec-title">Exact Tailwind classes & pixel values</div>
<div class="impl-ref">
<table>
<thead><tr><th>Element</th><th>Tailwind classes</th><th>Pixels / value</th><th>Notes</th></tr></thead>
<tbody>
<tr><td colspan="4" style="background:rgba(0,40,80,.05);font-size:10px;font-weight:700;color:var(--navy);text-transform:uppercase;letter-spacing:.08em;">Page chrome</td></tr>
<tr><td>Search bar wrapper</td><td><code>bg-white border-b border-line -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 py-3.5 flex items-center gap-3 sticky top-[65px] z-20</code></td><td>padding 14px responsive</td><td>Topbar = 1px accent + 64px nav = 65px. Negative margins break out of container padding so bar spans full container width.</td></tr>
<tr><td>Search input</td><td><code>flex-1 h-9 border border-ink rounded-sm px-3 font-sans text-sm text-ink bg-white</code></td><td>height 36px</td><td>Active: navy border</td></tr>
<tr><td>Sort bar wrapper</td><td><code>bg-white border-b border-line -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 py-2.5 flex items-center gap-3 sticky top-[113px] z-20</code></td><td>padding 10px responsive</td><td>Stacks below search bar (65 + 48 = 113px)</td></tr>
<tr><td>Filters toggle (closed)</td><td><code>h-7 px-3 border border-line rounded-sm font-sans text-[10px] font-bold uppercase tracking-wide text-ink flex items-center gap-1.5</code></td><td>height 28px</td><td></td></tr>
<tr><td>Filters toggle (open)</td><td><code>h-7 px-3 bg-ink text-white rounded-sm font-sans text-[10px] font-bold uppercase tracking-wide flex items-center gap-1.5</code></td><td>height 28px</td><td>Navy fill when active</td></tr>
<tr><td>Filter panel wrapper</td><td><code>bg-white border-b border-line -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 py-4 flex flex-col sm:flex-row sm:flex-wrap gap-4</code></td><td>padding 16px responsive</td><td>Use Svelte <code>slide</code> transition; stacks vertically on mobile</td></tr>
<tr><td>List body</td><td><code>py-5</code></td><td>vertical padding only</td><td>No extra horizontal padding — app container handles it</td></tr>
<tr><td colspan="4" style="background:rgba(0,40,80,.05);font-size:10px;font-weight:700;color:var(--navy);text-transform:uppercase;letter-spacing:.08em;">Year group card</td></tr>
<tr><td>Card container</td><td><code>border border-line bg-surface shadow-sm mb-4 overflow-hidden</code></td><td></td><td>Matches current DocumentList outer div exactly</td></tr>
<tr><td>Year header</td><td><code>bg-sand border-b border-line px-5 py-1.5 font-sans text-[10px] font-bold uppercase tracking-widest text-ink-3</code></td><td>padding 6px 20px</td><td></td></tr>
<tr><td>Row list</td><td><code>divide-y divide-line-2</code></td><td></td><td>Matches current <code>&lt;ul&gt;</code> pattern</td></tr>
<tr><td colspan="4" style="background:rgba(0,40,80,.05);font-size:10px;font-weight:700;color:var(--navy);text-transform:uppercase;letter-spacing:.08em;">Document row</td></tr>
<tr><td>Row wrapper <code>&lt;li&gt;</code></td><td><code>group transition-colors duration-200 hover:bg-muted/50</code></td><td></td><td>Same hover pattern as current</td></tr>
<tr><td>Row inner (link)</td><td><code>block sm:flex sm:items-stretch</code></td><td></td><td>Full-row <code>&lt;a href="/documents/{id}"&gt;</code>; flex only on sm+</td></tr>
<tr><td>Left column</td><td><code>p-4 sm:flex-1 sm:min-w-0 sm:pr-5 sm:border-r sm:border-line-2</code></td><td>padding 16px</td><td>Right border only on sm+</td></tr>
<tr><td>Right column (sm+)</td><td><code>hidden sm:flex sm:w-48 lg:w-60 flex-shrink-0 p-3.5 flex-col justify-between gap-2</code></td><td>sm: 192px · lg: 240px</td><td>Hidden on mobile; narrower on tablet</td></tr>
<tr><td>Mobile metadata grid</td><td><code>sm:hidden border-t border-line-2 mt-3 pt-3 grid grid-cols-2 gap-x-4 gap-y-0.5</code></td><td></td><td>2×2 compact grid shown only on mobile, inside left col</td></tr>
<tr><td>Mobile meta bottom row</td><td><code>sm:hidden flex items-center justify-between mt-3</code></td><td></td><td>Ring + contributors on mobile, shown only &lt;sm</td></tr>
<tr><td>Document title</td><td><code>font-serif text-base font-bold text-ink mb-1.5 leading-snug group-hover:underline</code></td><td>16px / 700</td><td></td></tr>
<tr><td>Snippet text</td><td><code>font-serif text-sm italic text-ink-2 line-clamp-2 mb-2</code></td><td>14px</td><td>Only when snippet present</td></tr>
<tr><td>Meta label</td><td><code>font-sans text-[10px] font-bold uppercase tracking-wide text-ink-3 mr-1.5</code></td><td>10px / 700</td><td>DATE · FROM · TO · ARCHIVE</td></tr>
<tr><td>Meta value</td><td><code>font-sans text-[11px] text-ink-2</code></td><td>11px</td><td></td></tr>
<tr><td colspan="4" style="background:rgba(0,40,80,.05);font-size:10px;font-weight:700;color:var(--navy);text-transform:uppercase;letter-spacing:.08em;">Progress ring</td></tr>
<tr><td>SVG container</td><td><code>relative w-9 h-9 flex-shrink-0</code></td><td>36×36px</td><td></td></tr>
<tr><td>Track circle</td><td><code>stroke="var(--c-sand)"</code> stroke-width="3"</td><td>r=13, circumference 81.7px</td><td></td></tr>
<tr><td>Fill arc</td><td><code>stroke="var(--c-accent)"</code> stroke-width="3" stroke-linecap="round"</td><td>dasharray = pct/100 × 81.7</td><td>rotate(90deg)</td></tr>
<tr><td>Percentage label</td><td><code>absolute inset-0 flex items-center justify-center font-sans text-[8px] font-bold</code></td><td>8px / 800</td><td>Mint when &gt;0, gray-400 when 0</td></tr>
<tr><td colspan="4" style="background:rgba(0,40,80,.05);font-size:10px;font-weight:700;color:var(--navy);text-transform:uppercase;letter-spacing:.08em;">New files</td></tr>
<tr><td><span class="new">NEW</span> <code>frontend/src/routes/documents/+page.svelte</code></td><td></td><td></td><td>Document list page (extract from homepage)</td></tr>
<tr><td><span class="new">NEW</span> <code>frontend/src/routes/documents/+page.server.ts</code></td><td></td><td></td><td>Loads search results, same API call as current homepage</td></tr>
<tr><td><span class="changed">CHANGED</span> <code>frontend/src/routes/AppNav.svelte</code></td><td></td><td></td><td>Documents tab href: <code>/</code><code>/documents</code></td></tr>
<tr><td><span class="changed">CHANGED</span> <code>frontend/src/routes/+page.svelte</code></td><td></td><td></td><td>Remove dual-mode logic; always render dashboard</td></tr>
<tr><td><span class="changed">CHANGED</span> <code>frontend/src/routes/+page.server.ts</code></td><td></td><td></td><td>Remove search branch; always fetch dashboard data</td></tr>
<tr><td><span class="changed">CHANGED</span> <code>frontend/src/routes/DocumentList.svelte</code></td><td></td><td></td><td>Refactor to new two-column layout + year cards</td></tr>
<tr><td><span class="new">NEW query</span> <code>backend/.../DocumentSearchRepository</code></td><td></td><td></td><td>Add completionPercentage + contributors to search projection</td></tr>
</tbody>
</table>
</div>
</div>
</div><!-- /doc -->
</body>
</html>

View File

@@ -363,6 +363,7 @@
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
"pdf_annotations_show": "Annotierungen anzeigen",
"pdf_annotations_hide": "Annotierungen verbergen",
"upload_action": "Hochladen",
"upload_drop_hint": "Einzeln oder mehrere Dateien auf einmal hochladen",
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
"upload_filename_hint": "Tipp: 2024-03-15_Mueller_Hans.pdf → Datum und Absender werden vorausgefüllt",
@@ -713,7 +714,7 @@
"greeting_evening": "Guten Abend, {name}.",
"dashboard_resume_label": "Weiter, wo du aufgehört hast",
"dashboard_page_of": "Seite {page} von {pages}",
"dashboard_blocks": "{count} Abschnitte",
"dashboard_resume_cta": "Weitertranskribieren",
"dashboard_resume_other": "oder anderen Brief wählen",
"dashboard_empty_title": "Noch kein Dokument begonnen",
@@ -739,6 +740,7 @@
"pulse_uploaded": "Dokumente hochgeladen",
"feed_caption": "Kommentare & Aktivität",
"feed_show_all": "Alle anzeigen",
"feed_for_you": "für dich",
"audit_action_text_saved": "hat Text gespeichert in",

View File

@@ -363,6 +363,7 @@
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
"pdf_annotations_show": "Show annotations",
"pdf_annotations_hide": "Hide annotations",
"upload_action": "Upload",
"upload_drop_hint": "Drop one or multiple files at once",
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
"upload_filename_hint": "Tip: 2024-03-15_Mueller_Hans.pdf → date and sender pre-filled",
@@ -713,7 +714,7 @@
"greeting_evening": "Good evening, {name}.",
"dashboard_resume_label": "Continue where you left off",
"dashboard_page_of": "Page {page} of {pages}",
"dashboard_blocks": "{count} sections",
"dashboard_resume_cta": "Continue transcribing",
"dashboard_resume_other": "or choose another document",
"dashboard_empty_title": "No document started yet",
@@ -739,6 +740,7 @@
"pulse_uploaded": "Documents uploaded",
"feed_caption": "Comments & activity",
"feed_show_all": "Show all",
"feed_for_you": "for you",
"audit_action_text_saved": "saved text in",

View File

@@ -363,6 +363,7 @@
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
"pdf_annotations_show": "Mostrar anotaciones",
"pdf_annotations_hide": "Ocultar anotaciones",
"upload_action": "Subir",
"upload_drop_hint": "Uno o varios archivos a la vez",
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
"upload_filename_hint": "Consejo: 2024-03-15_Mueller_Hans.pdf → fecha y remitente prellenados",
@@ -713,7 +714,7 @@
"greeting_evening": "Buenas noches, {name}.",
"dashboard_resume_label": "Continuar donde lo dejaste",
"dashboard_page_of": "Página {page} de {pages}",
"dashboard_blocks": "{count} secciones",
"dashboard_resume_cta": "Continuar transcripción",
"dashboard_resume_other": "o elige otro documento",
"dashboard_empty_title": "Aún no has comenzado ningún documento",
@@ -739,6 +740,7 @@
"pulse_uploaded": "Documentos subidos",
"feed_caption": "Comentarios y actividad",
"feed_show_all": "Ver todo",
"feed_for_you": "para ti",
"audit_action_text_saved": "guardó texto en",

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
type ActivityActorDTO = components['schemas']['ActivityActorDTO'];
interface Props {
contributors: ActivityActorDTO[];
hasMore: boolean;
}
let { contributors, hasMore }: Props = $props();
const safeContributors = $derived(contributors ?? []);
</script>
{#if safeContributors.length === 0}
<span
class="inline-block h-[22px] w-[22px] flex-shrink-0 rounded-full border-[1.5px] border-dashed border-[#cdcbbf]"
title="Noch niemand angefangen"
></span>
{:else}
<span class="inline-flex items-center">
{#each safeContributors as actor, i (actor.initials + '-' + actor.color)}
<span
role="img"
aria-label={actor.name ?? actor.initials}
class="inline-flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-full font-sans text-[9px] font-bold text-white ring-2 ring-white {i > 0 ? '-ml-1.5' : ''}"
style="background-color: {actor.color || '#8c9aa3'};"
title={actor.name ?? actor.initials}
>
{actor.initials}
</span>
{/each}
{#if hasMore}
<span
class="-ml-1.5 inline-flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-full bg-[#e4e2d7] font-sans text-[9px] font-bold text-ink-3 ring-2 ring-white"
>
</span>
{/if}
</span>
{/if}

View File

@@ -0,0 +1,50 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ContributorStack from './ContributorStack.svelte';
import type { components } from '$lib/generated/api';
type ActivityActorDTO = components['schemas']['ActivityActorDTO'];
afterEach(() => cleanup());
const makeActor = (overrides: Partial<ActivityActorDTO> = {}): ActivityActorDTO => ({
initials: 'MR',
color: '#7a4f9a',
name: 'Max Raddatz',
...overrides
});
describe('ContributorStack', () => {
it('contributor avatar is announced by screen readers with actor name', async () => {
const actor = makeActor({ name: 'Anna Meier', initials: 'AM' });
render(ContributorStack, { contributors: [actor], hasMore: false });
await expect.element(page.getByRole('img', { name: 'Anna Meier' })).toBeInTheDocument();
});
it('falls back to initials as accessible name when actor name is null', async () => {
const actor = makeActor({ name: undefined, initials: 'AM' });
render(ContributorStack, { contributors: [actor], hasMore: false });
await expect.element(page.getByRole('img', { name: 'AM' })).toBeInTheDocument();
});
it('renders two avatars without crashing when actors have identical initials', async () => {
const actors = [
makeActor({ name: undefined, initials: 'AM', color: '#aa0000' }),
makeActor({ name: undefined, initials: 'AM', color: '#0000bb' })
];
render(ContributorStack, { contributors: actors, hasMore: false });
await expect.element(page.getByText('AM').first()).toBeInTheDocument();
});
it('renders overflow indicator when hasMore is true', async () => {
render(ContributorStack, { contributors: [makeActor()], hasMore: true });
await expect.element(page.getByText('…')).toBeInTheDocument();
});
it('renders empty placeholder when no contributors', async () => {
render(ContributorStack, { contributors: [], hasMore: false });
await expect.element(page.getByTitle('Noch niemand angefangen')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import type { ActivityFeedItemDTO } from '$lib/generated/api';
interface Props {
feed: ActivityFeedItemDTO[];
}
const { feed }: Props = $props();
const verbMap: Record<string, string> = {
TEXT_SAVED: m.audit_action_text_saved(),
FILE_UPLOADED: m.audit_action_file_uploaded(),
ANNOTATION_CREATED: m.audit_action_annotation_created(),
COMMENT_ADDED: m.audit_action_comment_added(),
MENTION_CREATED: m.audit_action_mention_created()
};
function verb(kind: string): string {
return verbMap[kind] ?? kind;
}
function formatDate(iso: string): string {
return new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'short',
year: 'numeric'
}).format(new Date(iso));
}
</script>
<section class="rounded-sm border border-line bg-surface p-5">
<div class="mb-3 flex items-center justify-between">
<h2 class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.feed_caption()}
</h2>
<a
href="/documents"
aria-label={m.feed_show_all()}
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink">{m.feed_show_all()}</a
>
</div>
{#if feed.length > 0}
<ul class="flex flex-col gap-3">
{#each feed as item (item.happenedAt + item.documentId + item.kind)}
<li class="flex items-start gap-3">
{#if item.actor}
<span
class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full font-sans text-sm font-bold text-white"
style="background:{item.actor.color}">{item.actor.initials}</span
>
{:else}
<span
class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-line font-sans text-sm text-ink-3"
>?</span
>
{/if}
<div class="min-w-0 flex-1">
<p class="font-sans text-sm leading-snug text-ink">
{#if item.actor}
<strong>{item.actor.name ?? item.actor.initials}</strong>
{/if}
{verb(item.kind)}
<a href="/documents/{item.documentId}" class="underline hover:text-ink">
{item.documentTitle}
</a>
{#if item.youMentioned}
<span
class="ml-1.5 inline-block rounded-full border border-accent px-2 py-px font-sans text-[10px] font-bold text-accent"
>
{m.feed_for_you()}
</span>
{/if}
</p>
<p class="mt-0.5 font-sans text-xs text-ink-3">{formatDate(item.happenedAt)}</p>
</div>
</li>
{/each}
</ul>
{/if}
</section>

View File

@@ -0,0 +1,42 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DashboardActivityFeed from './DashboardActivityFeed.svelte';
import type { components } from '$lib/generated/api';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
afterEach(() => {
cleanup();
});
const baseItem: ActivityFeedItemDTO = {
kind: 'TEXT_SAVED',
actor: { initials: 'MR', color: '#7a4f9a', name: 'Max Raddatz' },
documentId: 'doc-1',
documentTitle: 'Brief 1920',
happenedAt: '2026-04-19T10:00:00Z',
youMentioned: false
};
describe('DashboardActivityFeed', () => {
it('renders "für dich" badge when youMentioned is true', async () => {
const item: ActivityFeedItemDTO = { ...baseItem, kind: 'MENTION_CREATED', youMentioned: true };
render(DashboardActivityFeed, { feed: [item] });
const badge = page.getByText('für dich');
await expect.element(badge).toBeInTheDocument();
});
it('does not render "für dich" badge when youMentioned is false', async () => {
render(DashboardActivityFeed, { feed: [baseItem] });
const badge = page.getByText('für dich');
await expect.element(badge).not.toBeInTheDocument();
});
it('renders empty state when feed is empty', async () => {
render(DashboardActivityFeed, { feed: [] });
const section = page.getByText('Kommentare & Aktivität');
await expect.element(section).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import type { DashboardPulseDTO } from '$lib/generated/api';
interface Props {
pulse: DashboardPulseDTO | null;
}
const { pulse }: Props = $props();
</script>
{#if pulse !== null}
<section class="rounded-sm border border-line bg-surface p-5">
<p class="font-sans text-[11px] font-bold tracking-[.12em] text-ink-3 uppercase">
{m.pulse_eyebrow()}
</p>
{#if pulse.pages > 0}
<h2 class="mt-1 font-serif text-[1.375rem] leading-snug text-ink">
{m.pulse_headline({ pages: pulse.pages })}
</h2>
{/if}
{#if pulse.yourPages > 0}
<p class="font-serif text-sm text-ink-2">
{m.pulse_you({ pages: pulse.yourPages })}
</p>
{/if}
{#if pulse.contributors.length > 0}
<div class="mt-3 flex items-center gap-1">
<p class="mr-1 font-sans text-[11px] text-ink-3">{m.pulse_contributors()}</p>
{#each pulse.contributors as c (c.initials + c.color)}
<span
class="-ml-2 inline-flex h-7 w-7 items-center justify-center rounded-full font-sans text-[11px] font-bold text-white ring-2 ring-white first:ml-0"
style="background:{c.color}"
title={c.name ?? ''}>{c.initials}</span
>
{/each}
</div>
{/if}
<div class="mt-4 grid grid-cols-3 gap-2">
<div class="flex flex-col gap-0.5">
<span class="font-serif text-[1.875rem] leading-none font-bold text-ink"
>{pulse.annotated}</span
>
<span class="flex items-center gap-1 font-sans text-[11px] text-ink-3">
<span class="text-[8px]" style="color:#00c7b1"></span>{m.pulse_transcribed()}
</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="font-serif text-[1.875rem] leading-none font-bold text-ink"
>{pulse.transcribed}</span
>
<span class="flex items-center gap-1 font-sans text-[11px] text-ink-3">
<span class="text-[8px]" style="color:#5a8a6a"></span>{m.pulse_reviewed()}
</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="font-serif text-[1.875rem] leading-none font-bold text-ink"
>{pulse.uploaded}</span
>
<span class="flex items-center gap-1 font-sans text-[11px] text-ink-3">
<span class="text-[8px]" style="color:#3060b0"></span>{m.pulse_uploaded()}
</span>
</div>
</div>
</section>
{/if}

View File

@@ -1,37 +1,115 @@
<script lang="ts">
import { onMount } from 'svelte';
import * as m from '$lib/paraglide/messages.js';
import type { DashboardResumeDTO } from '$lib/generated/api';
interface LastVisited {
id: string;
title: string;
interface Props {
resumeDoc: DashboardResumeDTO | null;
}
let lastVisited = $state<LastVisited | null>(null);
onMount(() => {
try {
const raw = localStorage.getItem('familienarchiv.lastVisited');
if (raw) {
const parsed = JSON.parse(raw) as LastVisited;
if (parsed?.id) {
lastVisited = parsed;
}
}
} catch {
// ignore malformed JSON
}
});
const { resumeDoc }: Props = $props();
</script>
{#if lastVisited}
{#if resumeDoc === null}
<div
data-testid="resume-strip"
class="flex items-center gap-2 rounded-sm border border-line bg-surface px-4 py-3 font-sans text-sm"
data-testid="resume-strip-empty"
class="rounded-sm border border-line bg-surface p-8 text-center"
>
<span class="text-ink-2">{m.dashboard_resume_label()}</span>
<a href="/documents/{lastVisited.id}" class="font-medium text-ink hover:underline">
{lastVisited.title || m.dashboard_resume_fallback()}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mx-auto text-ink-3"
>
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
<h2 class="mt-3 font-serif text-xl text-ink">{m.dashboard_empty_title()}</h2>
<p class="mt-1 font-sans text-sm text-ink-2">{m.dashboard_empty_body()}</p>
<a
href="/documents"
class="mt-4 inline-block font-sans text-sm font-bold text-accent hover:underline"
>
{m.dashboard_empty_cta()}
</a>
</div>
{:else}
<div data-testid="resume-strip" class="flex gap-4 rounded-sm border border-line bg-surface p-5">
<svg
xmlns="http://www.w3.org/2000/svg"
width="180"
height="246"
viewBox="0 0 180 246"
aria-hidden="true"
class="shrink-0"
>
<defs>
<linearGradient id="parchment" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#f5f0e8" />
<stop offset="100%" stop-color="#ede8d5" />
</linearGradient>
</defs>
<rect width="180" height="246" fill="url(#parchment)" />
<line x1="30" y1="40" x2="150" y2="40" stroke="#b0a898" stroke-width="1" />
<line x1="30" y1="70" x2="150" y2="70" stroke="#b0a898" stroke-width="1" />
<line x1="30" y1="100" x2="150" y2="100" stroke="#b0a898" stroke-width="1" />
<line x1="30" y1="130" x2="150" y2="130" stroke="#b0a898" stroke-width="1" />
<line x1="30" y1="160" x2="150" y2="160" stroke="#b0a898" stroke-width="1" />
</svg>
<div class="flex flex-1 flex-col gap-2">
<p class="flex items-center gap-1.5 font-sans text-xs text-ink-3">
<span class="text-[#A6DAD8]"></span>
{m.dashboard_resume_label()}
·
{m.dashboard_blocks({ count: resumeDoc.totalBlocks })}
</p>
<h2 class="font-serif text-[1.75rem] leading-tight text-ink">{resumeDoc.title}</h2>
<p class="font-sans text-sm text-ink-2 italic">{resumeDoc.caption}</p>
<blockquote
class="border-l-[3px] border-accent pl-3 font-serif text-[1.0625rem] leading-relaxed text-ink-2"
>
{resumeDoc.excerpt}
</blockquote>
<div class="mt-auto flex items-center gap-3 pt-2">
<span class="font-sans text-xs font-bold text-ink">{resumeDoc.pct}%</span>
<div
role="progressbar"
aria-valuenow={resumeDoc.pct}
aria-valuemin={0}
aria-valuemax={100}
class="h-1.5 flex-1 overflow-hidden rounded-full bg-line"
>
<div class="h-full rounded-full bg-accent" style="width:{resumeDoc.pct}%"></div>
</div>
{#each resumeDoc.collaborators.slice(0, 3) as collab (collab.initials + collab.color)}
<span
class="-ml-1 inline-flex h-6 w-6 items-center justify-center rounded-full font-sans text-[10px] font-bold text-white ring-2 ring-white"
style="background:{collab.color}">{collab.initials}</span
>
{/each}
</div>
<div class="mt-1 flex items-center gap-4">
<a
href="/documents/{resumeDoc.documentId}"
class="inline-block rounded-sm bg-accent px-4 py-1.5 font-sans text-sm font-bold text-white transition-opacity hover:opacity-90"
>
{m.dashboard_resume_cta()}
</a>
<a href="/documents" class="font-sans text-xs text-ink-3 transition-colors hover:text-ink">
{m.dashboard_resume_other()}
</a>
</div>
</div>
</div>
{/if}

View File

@@ -3,48 +3,53 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DashboardResumeStrip from './DashboardResumeStrip.svelte';
import type { components } from '$lib/generated/api';
type DashboardResumeDTO = components['schemas']['DashboardResumeDTO'];
afterEach(() => {
cleanup();
localStorage.clear();
});
const mockResume: DashboardResumeDTO = {
documentId: 'doc-123',
title: 'Geburtsurkunde 1920',
caption: 'Max Mustermann · 1920-01-01',
excerpt: 'Hiermit wird beurkundet…',
totalBlocks: 4,
pct: 75,
collaborators: []
};
describe('DashboardResumeStrip', () => {
it('renders nothing when no last-visited document in localStorage', async () => {
render(DashboardResumeStrip, {});
const strip = page.getByTestId('resume-strip');
await expect.element(strip).not.toBeInTheDocument();
it('renders empty state heading when resumeDoc is null', async () => {
render(DashboardResumeStrip, { resumeDoc: null });
const heading = page.getByRole('heading', { name: /Noch kein Dokument begonnen/i });
await expect.element(heading).toBeInTheDocument();
});
it('shows the strip with link when localStorage has a document', async () => {
localStorage.setItem(
'familienarchiv.lastVisited',
JSON.stringify({ id: 'doc-123', title: 'Geburtsurkunde 1920' })
);
render(DashboardResumeStrip, {});
const strip = page.getByTestId('resume-strip');
await expect.element(strip).toBeInTheDocument();
const link = page.getByRole('link', { name: /Geburtsurkunde 1920/ });
await expect.element(link).toBeInTheDocument();
it('renders progressbar with correct aria-valuenow when resumeDoc is provided', async () => {
render(DashboardResumeStrip, { resumeDoc: mockResume });
const bar = page.getByRole('progressbar');
await expect.element(bar).toBeInTheDocument();
await expect.element(bar).toHaveAttribute('aria-valuenow', '75');
});
it('shows document title when resumeDoc is provided', async () => {
render(DashboardResumeStrip, { resumeDoc: mockResume });
const title = page.getByRole('heading', { name: /Geburtsurkunde 1920/i });
await expect.element(title).toBeInTheDocument();
});
it('links to the document for the CTA', async () => {
render(DashboardResumeStrip, { resumeDoc: mockResume });
const link = page.getByRole('link', { name: /Weitertranskribieren/i });
await expect.element(link).toHaveAttribute('href', '/documents/doc-123');
});
it('uses title fallback text when title is empty', async () => {
localStorage.setItem(
'familienarchiv.lastVisited',
JSON.stringify({ id: 'doc-456', title: '' })
);
render(DashboardResumeStrip, {});
const strip = page.getByTestId('resume-strip');
await expect.element(strip).toBeInTheDocument();
const link = page.getByRole('link');
await expect.element(link).toHaveAttribute('href', '/documents/doc-456');
});
it('renders nothing when localStorage contains malformed JSON', async () => {
localStorage.setItem('familienarchiv.lastVisited', '{not valid json');
render(DashboardResumeStrip, {});
const strip = page.getByTestId('resume-strip');
await expect.element(strip).not.toBeInTheDocument();
it('shows block count label', async () => {
render(DashboardResumeStrip, { resumeDoc: mockResume });
const label = page.getByText(/4 Abschnitte/i);
await expect.element(label).toBeInTheDocument();
});
});

View File

@@ -20,6 +20,8 @@ function makeDoc(
annotationCount: 0,
textedBlockCount: 0,
reviewedBlockCount: 0,
contributors: [],
hasMoreContributors: false,
...overrides
};
}

View File

@@ -3,6 +3,7 @@ import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
import { formatMCDate } from '$lib/utils/date.js';
import type { components } from '$lib/generated/api';
import ContributorStack from './ContributorStack.svelte';
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
@@ -51,6 +52,9 @@ function reviewedPct(doc: TranscriptionQueueItemDTO): number {
</span>
{/if}
</div>
<div class="mt-1">
<ContributorStack contributors={doc.contributors} hasMore={doc.hasMoreContributors} />
</div>
</a>
</li>
{/each}

View File

@@ -15,6 +15,8 @@ function makeDoc(overrides: Partial<TranscriptionQueueItemDTO> = {}): Transcript
annotationCount: 0,
textedBlockCount: 0,
reviewedBlockCount: 0,
contributors: [],
hasMoreContributors: false,
...overrides
};
}

View File

@@ -3,6 +3,7 @@ import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
import { formatMCDate } from '$lib/utils/date.js';
import type { components } from '$lib/generated/api';
import ContributorStack from './ContributorStack.svelte';
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
@@ -44,6 +45,9 @@ let { docs, weeklyCount }: Props = $props();
>{formatMCDate(doc.documentDate, getLocale())}</span
>
{/if}
<div class="mt-1">
<ContributorStack contributors={doc.contributors} hasMore={doc.hasMoreContributors} />
</div>
</a>
</li>
{/each}

View File

@@ -15,6 +15,8 @@ function makeDoc(overrides: Partial<TranscriptionQueueItemDTO> = {}): Transcript
annotationCount: 0,
textedBlockCount: 0,
reviewedBlockCount: 0,
contributors: [],
hasMoreContributors: false,
...overrides
};
}

View File

@@ -3,6 +3,7 @@ import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
import { formatMCDate } from '$lib/utils/date.js';
import type { components } from '$lib/generated/api';
import ContributorStack from './ContributorStack.svelte';
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
@@ -67,6 +68,9 @@ function blockProgress(doc: TranscriptionQueueItemDTO): number {
{:else}
<span class="mt-0.5 text-xs text-ink-3 italic"></span>
{/if}
<div class="mt-1">
<ContributorStack contributors={doc.contributors} hasMore={doc.hasMoreContributors} />
</div>
</a>
</li>
{/each}

View File

@@ -15,6 +15,8 @@ function makeDoc(overrides: Partial<TranscriptionQueueItemDTO> = {}): Transcript
annotationCount: 0,
textedBlockCount: 0,
reviewedBlockCount: 0,
contributors: [],
hasMoreContributors: false,
...overrides
};
}

View File

@@ -1754,6 +1754,11 @@ export interface components {
/** Format: int64 */
transcriptionCount: number;
};
ActivityActorDTO: {
initials: string;
color: string;
name?: string;
};
TranscriptionQueueItemDTO: {
/** Format: uuid */
id: string;
@@ -1766,6 +1771,8 @@ export interface components {
textedBlockCount: number;
/** Format: int32 */
reviewedBlockCount: number;
contributors: components["schemas"]["ActivityActorDTO"][];
hasMoreContributors: boolean;
};
TagTreeNodeDTO: {
/** Format: uuid */
@@ -1968,11 +1975,6 @@ export interface components {
summarySnippet?: string;
summaryOffsets: components["schemas"]["MatchOffset"][];
};
ActivityActorDTO: {
initials: string;
color: string;
name?: string;
};
DashboardResumeDTO: {
/** Format: uuid */
documentId: string;
@@ -1980,9 +1982,7 @@ export interface components {
caption: string;
excerpt: string;
/** Format: int32 */
page: number;
/** Format: int32 */
pages: number;
totalBlocks: number;
/** Format: int32 */
pct: number;
thumbnailUrl?: string;
@@ -4451,3 +4451,7 @@ export interface operations {
};
};
}
export type DashboardResumeDTO = components['schemas']['DashboardResumeDTO'];
export type DashboardPulseDTO = components['schemas']['DashboardPulseDTO'];
export type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];

View File

@@ -2,6 +2,7 @@
import './layout.css';
import { page } from '$app/state';
import { onMount } from 'svelte';
import * as m from '$lib/paraglide/messages.js';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import NotificationBell from '$lib/components/NotificationBell.svelte';
@@ -28,7 +29,9 @@ onMount(() => {
});
const isAuthPage = $derived(
['/login', '/forgot-password', '/reset-password'].some((p) => page.url.pathname.startsWith(p))
['/login', '/register', '/forgot-password', '/reset-password'].some((p) =>
page.url.pathname.startsWith(p)
)
);
const userInitials = $derived.by(() => {
@@ -50,6 +53,31 @@ const userInitials = $derived.by(() => {
<!-- Right Side -->
<div class="flex items-center gap-3">
{#if data?.user}
<a
href="/documents/new"
aria-label={m.upload_action()}
class="inline-flex items-center gap-2 rounded-sm border border-white/25 px-3.5 py-1.5 font-sans text-[11px] font-bold tracking-[.12em] text-white uppercase transition-colors hover:bg-white/10"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
{m.upload_action()}
</a>
{/if}
<!-- Language selector (desktop only — mobile lives in nav drawer) -->
<div
class="hidden items-center gap-1 pr-3 lg:flex [&_button]:px-1.5 [&_button]:py-1 [&_button]:text-xs"

View File

@@ -2,12 +2,14 @@ import { redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
import type { components } from '$lib/generated/api';
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
type StatsDTO = components['schemas']['StatsDTO'];
type Document = components['schemas']['Document'];
type SearchMatchData = components['schemas']['SearchMatchData'];
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
type TranscriptionWeeklyStatsDTO = components['schemas']['TranscriptionWeeklyStatsDTO'];
type DashboardResumeDTO = components['schemas']['DashboardResumeDTO'];
type DashboardPulseDTO = components['schemas']['DashboardPulseDTO'];
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
export async function load({ url, fetch }) {
const q = url.searchParams.get('q') || '';
@@ -81,10 +83,10 @@ export async function load({ url, fetch }) {
const senderObj = allPersons.find((p) => p.id === senderId);
const receiverObj = allPersons.find((p) => p.id === receiverId);
// Dashboard widgets — failures are isolated and don't crash the page
let stats: StatsDTO | null = null;
let incompleteDocs: IncompleteDocumentDTO[] = [];
let recentDocs: Document[] = [];
let resumeDoc: DashboardResumeDTO | null = null;
let pulse: DashboardPulseDTO | null = null;
let activityFeed: ActivityFeedItemDTO[] = [];
let segmentationDocs: TranscriptionQueueItemDTO[] = [];
let transcriptionDocs: TranscriptionQueueItemDTO[] = [];
let readyDocs: TranscriptionQueueItemDTO[] = [];
@@ -93,16 +95,18 @@ export async function load({ url, fetch }) {
if (isDashboard) {
const [
statsResult,
incompleteResult,
recentResult,
resumeResult,
pulseResult,
activityResult,
segmentationResult,
transcriptionResult,
readyResult,
weeklyStatsResult
] = await Promise.allSettled([
api.GET('/api/stats'),
api.GET('/api/documents/incomplete', { params: { query: { size: 3 } } }),
api.GET('/api/documents/recent-activity', { params: { query: { size: 5 } } }),
api.GET('/api/dashboard/resume'),
api.GET('/api/dashboard/pulse'),
api.GET('/api/dashboard/activity', { params: { query: { limit: 7 } } }),
api.GET('/api/transcription/segmentation-queue'),
api.GET('/api/transcription/transcription-queue'),
api.GET('/api/transcription/ready-to-read'),
@@ -112,11 +116,14 @@ export async function load({ url, fetch }) {
if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) {
stats = statsResult.value.data ?? null;
}
if (incompleteResult.status === 'fulfilled' && incompleteResult.value.response.ok) {
incompleteDocs = incompleteResult.value.data ?? [];
if (resumeResult.status === 'fulfilled' && resumeResult.value.response.ok) {
resumeDoc = (resumeResult.value.data as DashboardResumeDTO) ?? null;
}
if (recentResult.status === 'fulfilled' && recentResult.value.response.ok) {
recentDocs = recentResult.value.data ?? [];
if (pulseResult.status === 'fulfilled' && pulseResult.value.response.ok) {
pulse = (pulseResult.value.data as DashboardPulseDTO) ?? null;
}
if (activityResult.status === 'fulfilled' && activityResult.value.response.ok) {
activityFeed = (activityResult.value.data as ActivityFeedItemDTO[]) ?? [];
}
if (segmentationResult.status === 'fulfilled' && segmentationResult.value.response.ok) {
segmentationDocs = (segmentationResult.value.data ?? []) as TranscriptionQueueItemDTO[];
@@ -138,15 +145,16 @@ export async function load({ url, fetch }) {
total,
matchData,
stats,
incompleteDocs,
recentDocs,
resumeDoc,
pulse,
activityFeed,
segmentationDocs,
transcriptionDocs,
readyDocs,
weeklyStats,
initialValues: {
senderName: senderObj?.displayName ?? '',
receiverName: receiverObj?.displayName ?? ''
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}`.trim() : '',
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}`.trim() : ''
},
filters: { q, from, to, senderId, receiverId, tags, sort, dir, tagQ, tagOp },
error: null as string | null
@@ -160,8 +168,9 @@ export async function load({ url, fetch }) {
total: 0,
matchData: {} as Record<string, SearchMatchData>,
stats: null,
incompleteDocs: [],
recentDocs: [],
resumeDoc: null,
pulse: null,
activityFeed: [],
segmentationDocs: [],
transcriptionDocs: [],
readyDocs: [],

View File

@@ -7,10 +7,10 @@ import SearchFilterBar from './SearchFilterBar.svelte';
import DropZone from './DropZone.svelte';
import DocumentList from './DocumentList.svelte';
import DashboardResumeStrip from '$lib/components/DashboardResumeStrip.svelte';
import DashboardNeedsMetadata from '$lib/components/DashboardNeedsMetadata.svelte';
import DashboardRecentDocuments from '$lib/components/DashboardRecentDocuments.svelte';
import MissionControlStrip from '$lib/components/MissionControlStrip.svelte';
import { m } from '$lib/paraglide/messages.js';
import DashboardFamilyPulse from '$lib/components/DashboardFamilyPulse.svelte';
import DashboardActivityFeed from '$lib/components/DashboardActivityFeed.svelte';
import * as m from '$lib/paraglide/messages.js';
let { data } = $props();
@@ -66,7 +66,6 @@ function handleImmediateSearch() {
triggerSearch();
}
// Trigger search when tags change
let prevTagStr = untrack(() => tagNames.map((t) => t.name).join(','));
$effect(() => {
const cur = tagNames.map((t) => t.name).join(',');
@@ -76,8 +75,6 @@ $effect(() => {
}
});
// Sync local state with server data after navigation.
// Guard q: skip overwrite while the user is actively typing in the search field.
$effect(() => {
if (!qFocused) q = data.filters?.q || '';
from = data.filters?.from || '';
@@ -92,10 +89,13 @@ $effect(() => {
if (hasAdvancedFilters(data.filters)) showAdvanced = true;
});
// Right column is only rendered when there is something to show.
// Omitting it prevents an empty 300px ghost column for read-only users
// with a complete archive.
const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?? 0) > 0);
const greetingText = $derived.by(() => {
const name = data?.user?.firstName ?? '';
const h = new Date().getHours();
if (h < 12) return m.greeting_morning({ name });
if (h < 18) return m.greeting_day({ name });
return m.greeting_evening({ name });
});
</script>
<svelte:head>
@@ -125,35 +125,37 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?
/>
{#if data.isDashboard}
<DashboardResumeStrip />
{#if data?.user}
<div class="mb-6">
<h1 class="font-serif text-[2rem] text-ink">{greetingText}</h1>
</div>
{/if}
<!-- Classic Split: right column first in DOM so it appears above recent docs on mobile.
lg:order-last moves it back to the visual right on desktop. -->
<!-- No items-start — CSS Grid stretch default makes both columns equal height -->
<div class="mt-4 grid grid-cols-1 gap-4 {showRightColumn ? 'lg:grid-cols-[1fr_300px]' : ''}">
{#if showRightColumn}
<div data-testid="dashboard-right-column" class="flex h-full flex-col gap-4 lg:order-last">
{#if data.canWrite}
<DropZone />
{/if}
<!-- flex-1 + min-h-0 fills remaining height after DropZone.
min-h-0 overrides the default min-height:auto that prevents flex
children from shrinking below their content size. -->
<div class="flex min-h-0 flex-1 flex-col">
<DashboardNeedsMetadata incompleteDocs={data.incompleteDocs ?? []} />
</div>
</div>
{/if}
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
<div class="flex flex-col gap-5">
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} stats={data.stats} />
<section aria-label={m.dashboard_mission_caption()}>
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.dashboard_mission_caption()}
</h2>
<MissionControlStrip
segmentationDocs={data.segmentationDocs ?? []}
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 />
{/if}
</div>
</div>
<MissionControlStrip
segmentationDocs={data.segmentationDocs ?? []}
transcriptionDocs={data.transcriptionDocs ?? []}
readyDocs={data.readyDocs ?? []}
weeklyStats={data.weeklyStats ?? null}
/>
{:else}
<DocumentList
documents={data.documents ?? []}

View File

@@ -48,6 +48,22 @@ describe('Layout user avatar button', () => {
});
});
// ─── Upload link ──────────────────────────────────────────────────────────────
describe('Layout upload link', () => {
it('has aria-label for screen reader access', async () => {
render(Layout, { data: makeData(), children: emptySnippet });
const link = page.getByRole('link', { name: /Hochladen|Upload|Subir/i });
await expect.element(link).toHaveAttribute('aria-label');
});
it('navigates to /documents/new', async () => {
render(Layout, { data: makeData(), children: emptySnippet });
const link = page.getByRole('link', { name: /Hochladen|Upload|Subir/i });
await expect.element(link).toHaveAttribute('href', '/documents/new');
});
});
// ─── Dropdown ─────────────────────────────────────────────────────────────────
describe('Layout user dropdown', () => {

View File

@@ -22,7 +22,7 @@ function makeUrl(params: Record<string, string | string[]> = {}) {
// ─── dashboard mode (no search filters) ──────────────────────────────────────
describe('home page load — dashboard mode', () => {
it('sets isDashboard true and fetches stats, incomplete, and recent APIs', async () => {
it('sets isDashboard true and fetches stats, resume, pulse, and activity APIs', async () => {
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
@@ -30,8 +30,30 @@ describe('home page load — dashboard mode', () => {
response: { ok: true },
data: { totalDocuments: 42, totalPersons: 7 }
}) // stats
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd1' }] }) // incomplete
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd2' }] }) // recent
.mockResolvedValueOnce({
response: { ok: true },
data: {
documentId: 'd1',
title: 'T',
caption: '',
excerpt: '',
totalBlocks: 2,
pct: 50,
collaborators: []
}
}) // resume
.mockResolvedValueOnce({
response: { ok: true },
data: {
pages: 5,
annotated: 1,
transcribed: 2,
uploaded: 1,
yourPages: 3,
contributors: []
}
}) // pulse
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // activity
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // segmentation-queue
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // transcription-queue
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // ready-to-read
@@ -47,8 +69,10 @@ describe('home page load — dashboard mode', () => {
expect(result.isDashboard).toBe(true);
expect(result.stats).toEqual({ totalDocuments: 42, totalPersons: 7 });
expect(result.incompleteDocs).toHaveLength(1);
expect(result.recentDocs).toHaveLength(1);
expect(result.resumeDoc).not.toBeNull();
expect(result.resumeDoc?.totalBlocks).toBe(2);
expect(result.pulse).not.toBeNull();
expect(result.activityFeed).toEqual([]);
expect(result.documents).toEqual([]);
});
@@ -60,8 +84,9 @@ describe('home page load — dashboard mode', () => {
response: { ok: true },
data: { totalDocuments: 248, totalPersons: 34 }
}) // stats
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // recent
.mockResolvedValueOnce({ response: { ok: true }, data: null }) // resume
.mockResolvedValueOnce({ response: { ok: true }, data: null }) // pulse
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // activity
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // segmentation-queue
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // transcription-queue
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // ready-to-read
@@ -95,23 +120,24 @@ describe('home page load — dashboard mode', () => {
expect(result.stats).toBeNull();
});
it('defaults incompleteDocs to [] when incomplete API rejects', async () => {
it('defaults resumeDoc to null when resume API rejects', async () => {
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
.mockResolvedValueOnce({ response: { ok: true }, data: { content: [] } }) // notifications
.mockRejectedValueOnce(new Error('network')) // incomplete
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent
.mockResolvedValueOnce({ response: { ok: true }, data: { content: [] } }) // stats
.mockRejectedValueOnce(new Error('network')) // resume
.mockResolvedValueOnce({ response: { ok: true }, data: null }) // pulse
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // activity
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
expect(result.incompleteDocs).toEqual([]);
expect(result.resumeDoc).toBeNull();
});
it('defaults recentDocs to [] when recent-activity API rejects', async () => {
it('defaults activityFeed to [] when activity API rejects', async () => {
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
@@ -119,15 +145,16 @@ describe('home page load — dashboard mode', () => {
response: { ok: true },
data: { totalDocuments: 0, totalPersons: 0 }
}) // stats
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
.mockRejectedValueOnce(new Error('network')); // recent
.mockResolvedValueOnce({ response: { ok: true }, data: null }) // resume
.mockResolvedValueOnce({ response: { ok: true }, data: null }) // pulse
.mockRejectedValueOnce(new Error('network')); // activity
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
expect(result.recentDocs).toEqual([]);
expect(result.activityFeed).toEqual([]);
});
});
@@ -154,8 +181,8 @@ describe('home page load — search mode', () => {
expect(result.isDashboard).toBe(false);
expect(result.documents).toHaveLength(1);
expect(result.stats).toBeNull();
expect(result.incompleteDocs).toEqual([]);
expect(result.recentDocs).toEqual([]);
expect(result.resumeDoc).toBeNull();
expect(result.activityFeed).toEqual([]);
// Only two API calls — no widget calls
expect(mockGet).toHaveBeenCalledTimes(2);
});

View File

@@ -40,10 +40,10 @@ const emptyData = {
string,
import('$lib/generated/api').components['schemas']['SearchMatchData']
>,
incompleteDocs: [],
recentDocs: [],
resumeDoc: null,
pulse: null,
activityFeed: [],
stats: null,
incompleteCount: 0,
initialValues: { senderName: '', receiverName: '' },
segmentationDocs: [],
transcriptionDocs: [],
@@ -233,33 +233,31 @@ describe('Home page dashboard mode', () => {
const dashboardData = {
...emptyData,
isDashboard: true,
canWrite: false,
incompleteDocs: [],
recentDocs: []
resumeDoc: null,
pulse: null,
activityFeed: []
};
it('hides the right column when canWrite is false and incompleteDocs is empty', async () => {
it('renders empty state when resumeDoc is null', async () => {
render(Page, { data: dashboardData });
const rightCol = page.getByTestId('dashboard-right-column');
await expect.element(rightCol).not.toBeInTheDocument();
const empty = page.getByTestId('resume-strip-empty');
await expect.element(empty).toBeInTheDocument();
});
it('shows the right column when canWrite is true', async () => {
render(Page, { data: { ...dashboardData, canWrite: true } });
const rightCol = page.getByTestId('dashboard-right-column');
await expect.element(rightCol).toBeInTheDocument();
});
it('shows the right column when incompleteDocs is non-empty', async () => {
render(Page, {
data: {
...dashboardData,
canWrite: false,
incompleteDocs: [{ id: 'd1', title: 'Taufschein' }]
}
});
const rightCol = page.getByTestId('dashboard-right-column');
await expect.element(rightCol).toBeInTheDocument();
it('renders resume card when resumeDoc is provided', async () => {
const resume = {
documentId: 'doc-1',
title: 'Geburtsurkunde',
caption: 'Max · 1920',
excerpt: 'Hiermit…',
page: 1,
pages: 3,
pct: 33,
collaborators: []
};
render(Page, { data: { ...dashboardData, resumeDoc: resume } });
const strip = page.getByTestId('resume-strip');
await expect.element(strip).toBeInTheDocument();
});
});