feat(dashboard): redesign home as action-led family archive hub (#271) #278

Merged
marcel merged 29 commits from feat/issue-271-dashboard-redesign into main 2026-04-20 07:45:17 +02:00
60 changed files with 3068 additions and 414 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

@@ -0,0 +1,109 @@
package org.raddatz.familienarchiv.audit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
@Query(value = """
SELECT a.document_id
FROM audit_log a
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
LIMIT 1
""", nativeQuery = true)
Optional<UUID> findMostRecentDocumentIdByActor(@Param("userId") UUID userId);
@Query(value = """
SELECT * FROM (
SELECT DISTINCT ON (a.actor_id, a.document_id, a.kind, date_trunc('hour', a.happened_at))
a.kind AS kind,
a.actor_id AS actorId,
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,
a.document_id AS documentId,
a.happened_at AS happened_at,
(a.kind = 'MENTION_CREATED'
AND a.payload->>'mentionedUserId' = :currentUserId) AS youMentioned
FROM audit_log a
LEFT JOIN users u ON u.id = a.actor_id
WHERE a.kind IN ('TEXT_SAVED','FILE_UPLOADED','ANNOTATION_CREATED','COMMENT_ADDED','MENTION_CREATED')
AND a.document_id IS NOT NULL
ORDER BY a.actor_id, a.document_id, a.kind,
date_trunc('hour', a.happened_at), a.happened_at DESC
) deduped
ORDER BY happened_at DESC
LIMIT :limit
""", nativeQuery = true)
List<ActivityFeedRow> findDedupedActivityFeed(
@Param("currentUserId") String currentUserId,
@Param("limit") int limit);
@Query(value = """
SELECT
COUNT(DISTINCT (a.document_id::text || '|' || (a.payload->>'pageNumber'))) AS pages,
COUNT(*) FILTER (WHERE a.kind = 'ANNOTATION_CREATED') AS annotated,
COUNT(DISTINCT a.payload->>'blockId') FILTER (WHERE a.kind = 'TEXT_SAVED') AS transcribed,
COUNT(DISTINCT a.document_id) FILTER (WHERE a.kind = 'FILE_UPLOADED') AS uploaded,
COUNT(DISTINCT (a.document_id::text || '|' || (a.payload->>'pageNumber')))
FILTER (WHERE (a.kind = 'ANNOTATION_CREATED' OR a.kind = 'TEXT_SAVED')
AND a.actor_id::text = :userId) AS yourPages
FROM audit_log a
WHERE a.happened_at >= :weekStart
AND a.kind IN ('ANNOTATION_CREATED','TEXT_SAVED','FILE_UPLOADED')
""", nativeQuery = true)
PulseStatsRow getPulseStats(
@Param("weekStart") OffsetDateTime weekStart,
@Param("userId") String userId);
@Query(value = """
SELECT DISTINCT ON (a.document_id)
a.document_id AS documentId,
a.actor_id AS actorId
FROM audit_log a
WHERE a.kind = :kind
AND a.document_id IN :documentIds
AND a.actor_id IS NOT NULL
ORDER BY a.document_id, a.happened_at DESC
""", nativeQuery = true)
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

@@ -17,7 +17,6 @@ import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.TagOperator;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
@@ -28,6 +27,7 @@ import org.raddatz.familienarchiv.model.DocumentVersion;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.security.SecurityUtils;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.DocumentVersionService;
import org.raddatz.familienarchiv.service.FileService;
@@ -197,12 +197,6 @@ public class DocumentController {
return Map.of("count", documentService.getIncompleteCount());
}
@GetMapping("/incomplete")
public List<IncompleteDocumentDTO> getIncomplete(
@Parameter(description = "Maximum number of results") @RequestParam(defaultValue = "10") int size) {
return documentService.findIncompleteDocuments(size);
}
@GetMapping("/incomplete/next")
public ResponseEntity<Document> getNextIncomplete(@RequestParam UUID excludeId) {
return documentService.findNextIncompleteDocument(excludeId)
@@ -210,12 +204,6 @@ public class DocumentController {
.orElse(ResponseEntity.noContent().build());
}
@GetMapping("/recent-activity")
public ResponseEntity<List<Document>> getRecentActivity(
@RequestParam(defaultValue = "5") int size) {
return ResponseEntity.ok(documentService.getRecentActivity(size));
}
@GetMapping("/search")
public ResponseEntity<DocumentSearchResult> search(
@RequestParam(required = false) String q,
@@ -286,13 +274,6 @@ public class DocumentController {
}
private UUID requireUserId(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
throw DomainException.unauthorized("Authentication required");
}
AppUser user = userService.findByEmail(authentication.getName());
if (user == null) {
throw DomainException.unauthorized("User not found");
}
return user.getId();
return SecurityUtils.requireUserId(authentication, userService);
}
}

View File

@@ -5,12 +5,11 @@ import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
import org.raddatz.familienarchiv.dto.ReorderTranscriptionBlocksDTO;
import org.raddatz.familienarchiv.dto.UpdateTranscriptionBlockDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.security.SecurityUtils;
import org.raddatz.familienarchiv.service.TranscriptionService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.http.HttpStatus;
@@ -100,13 +99,6 @@ public class TranscriptionBlockController {
}
private UUID requireUserId(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
throw DomainException.unauthorized("Authentication required");
}
AppUser user = userService.findByEmail(authentication.getName());
if (user == null) {
throw DomainException.unauthorized("User not found");
}
return user.getId();
return SecurityUtils.requireUserId(authentication, userService);
}
}

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

@@ -19,6 +19,10 @@ import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import jakarta.persistence.PostLoad;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
@Entity
@Table(name = "users")
@Data
@@ -74,6 +78,28 @@ public class AppUser {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createdAt;
@Column(nullable = false)
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String color = "";
private static final String[] PALETTE = {
"#7a4f9a", "#5a8a6a", "#3060b0", "#a0522d", "#c0446e", "#c17a00", "#0e7490", "#1d4ed8"
};
public static String computeColor(UUID id) {
return PALETTE[Math.abs(id.hashCode()) % PALETTE.length];
}
@PrePersist
@PreUpdate
@PostLoad
void deriveColor() {
if (id != null && (color == null || color.isEmpty())) {
this.color = computeColor(id);
}
}
public boolean hasPermission(String permission) {
if (groups == null || groups.isEmpty()) {
return false;

View File

@@ -0,0 +1,24 @@
package org.raddatz.familienarchiv.security;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.security.core.Authentication;
import java.util.UUID;
public final class SecurityUtils {
private SecurityUtils() {}
public static UUID requireUserId(Authentication authentication, UserService userService) {
if (authentication == null || !authentication.isAuthenticated()) {
throw DomainException.unauthorized("Authentication required");
}
AppUser user = userService.findByEmail(authentication.getName());
if (user == null) {
throw DomainException.unauthorized("User not found");
}
return user.getId();
}
}

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

@@ -142,7 +142,8 @@ public class TranscriptionService {
if (!text.equals(previousText)) {
Optional<DocumentAnnotation> annotation = annotationRepository.findById(block.getAnnotationId());
int pageNumber = annotation.map(DocumentAnnotation::getPageNumber).orElse(0);
auditService.logAfterCommit(AuditKind.TEXT_SAVED, userId, documentId, Map.of("pageNumber", pageNumber));
auditService.logAfterCommit(AuditKind.TEXT_SAVED, userId, documentId,
Map.of("pageNumber", pageNumber, "blockId", saved.getId().toString()));
}
Document doc = documentService.getDocumentById(documentId);

View File

@@ -6,7 +6,7 @@ CREATE TABLE audit_log (
happened_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- ON DELETE SET NULL is by design: GDPR right-to-erasure. Deleted users' events
-- retain their timestamp and kind but lose actor attribution.
actor_id UUID REFERENCES app_users(id) ON DELETE SET NULL,
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
kind VARCHAR(50) NOT NULL,
document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
payload JSONB
@@ -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.
REVOKE UPDATE, DELETE ON audit_log FROM app_user;
-- 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,8 @@
-- Add deterministic avatar color to app_users.
-- Assigned at application layer (AppUser.java) from a fixed 8-colour palette.
-- Also corrects V46's REVOKE which hardcoded 'app_user' instead of CURRENT_USER.
ALTER TABLE users ADD COLUMN color VARCHAR(20) NOT NULL DEFAULT '';
-- Fix V46 append-only enforcement for the actual application role.
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

@@ -3,7 +3,6 @@ package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
@@ -390,47 +389,14 @@ class DocumentControllerTest {
.andExpect(jsonPath("$.count").value(3));
}
// ─── GET /api/documents/incomplete ───────────────────────────────────────
@Test
void getIncomplete_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/incomplete"))
.andExpect(status().isUnauthorized());
}
// ─── GET /api/documents/incomplete (removed — superseded by dashboard) ────
@Test
@WithMockUser
void getIncomplete_returns200_withDTOList() throws Exception {
UUID id = UUID.randomUUID();
IncompleteDocumentDTO dto = new IncompleteDocumentDTO(id, "Unvollständig");
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of(dto));
void getIncomplete_endpointRemoved() throws Exception {
// The path hits /{id} and fails UUID conversion — not a 200 anymore
mockMvc.perform(get("/api/documents/incomplete"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value(id.toString()))
.andExpect(jsonPath("$[0].title").value("Unvollständig"));
}
@Test
@WithMockUser
void getIncomplete_withSizeParam_passesItToService() throws Exception {
when(documentService.findIncompleteDocuments(5)).thenReturn(List.of());
mockMvc.perform(get("/api/documents/incomplete").param("size", "5"))
.andExpect(status().isOk());
verify(documentService).findIncompleteDocuments(5);
}
@Test
@WithMockUser
void getIncomplete_usesDefaultSizeWhenNotSpecified() throws Exception {
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of());
mockMvc.perform(get("/api/documents/incomplete"))
.andExpect(status().isOk());
verify(documentService).findIncompleteDocuments(10);
.andExpect(status().is4xxClientError());
}
// ─── GET /api/documents/incomplete/next ──────────────────────────────────
@@ -467,36 +433,14 @@ class DocumentControllerTest {
.andExpect(status().isNoContent());
}
// ─── GET /api/documents/recent-activity ──────────────────────────────────
@Test
void getRecentActivity_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/recent-activity"))
.andExpect(status().isUnauthorized());
}
// ─── GET /api/documents/recent-activity (removed — superseded by dashboard)
@Test
@WithMockUser
void getRecentActivity_returnsOkWithDocuments() throws Exception {
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build();
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build();
when(documentService.getRecentActivity(5)).thenReturn(List.of(doc1, doc2));
mockMvc.perform(get("/api/documents/recent-activity").param("size", "5"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value("Alpha"))
.andExpect(jsonPath("$[1].title").value("Beta"));
}
@Test
@WithMockUser
void getRecentActivity_appliesDefaultSizeOfFive_whenSizeParamOmitted() throws Exception {
when(documentService.getRecentActivity(5)).thenReturn(List.of());
void getRecentActivity_endpointRemoved() throws Exception {
// The path hits /{id} and fails UUID conversion — not a 200 anymore
mockMvc.perform(get("/api/documents/recent-activity"))
.andExpect(status().isOk());
verify(documentService).getRecentActivity(5);
.andExpect(status().is4xxClientError());
}
// ─── GET /api/documents/{id}/versions ────────────────────────────────────

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

@@ -0,0 +1,38 @@
package org.raddatz.familienarchiv.model;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class AppUserTest {
private static final List<String> EXPECTED_PALETTE = List.of(
"#7a4f9a", "#5a8a6a", "#3060b0", "#a0522d", "#c0446e", "#c17a00", "#0e7490", "#1d4ed8"
);
@Test
void computeColor_returnsDeterministicPaletteColor() {
UUID id = UUID.fromString("12345678-1234-1234-1234-123456789abc");
String color = AppUser.computeColor(id);
assertThat(EXPECTED_PALETTE).contains(color);
assertThat(AppUser.computeColor(id)).isEqualTo(color);
}
@Test
void computeColor_isStableAcrossCalls() {
UUID id = UUID.randomUUID();
assertThat(AppUser.computeColor(id)).isEqualTo(AppUser.computeColor(id));
}
@Test
void computeColor_variesAcrossDifferentIds() {
long distinct = java.util.stream.IntStream.range(0, 100)
.mapToObj(i -> AppUser.computeColor(UUID.randomUUID()))
.distinct()
.count();
assertThat(distinct).isGreaterThan(1);
}
}

View File

@@ -0,0 +1,58 @@
package org.raddatz.familienarchiv.security;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.security.core.Authentication;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SecurityUtilsTest {
@Mock Authentication authentication;
@Mock UserService userService;
@Test
void requireUserId_throwsUnauthorized_whenAuthenticationIsNull() {
assertThatThrownBy(() -> SecurityUtils.requireUserId(null, userService))
.isInstanceOf(DomainException.class);
}
@Test
void requireUserId_throwsUnauthorized_whenNotAuthenticated() {
when(authentication.isAuthenticated()).thenReturn(false);
assertThatThrownBy(() -> SecurityUtils.requireUserId(authentication, userService))
.isInstanceOf(DomainException.class);
}
@Test
void requireUserId_throwsUnauthorized_whenUserNotFound() {
when(authentication.isAuthenticated()).thenReturn(true);
when(authentication.getName()).thenReturn("ghost@example.com");
when(userService.findByEmail("ghost@example.com")).thenReturn(null);
assertThatThrownBy(() -> SecurityUtils.requireUserId(authentication, userService))
.isInstanceOf(DomainException.class);
}
@Test
void requireUserId_returnsUserId_whenAuthenticated() {
UUID userId = UUID.randomUUID();
AppUser user = AppUser.builder().id(userId).email("user@example.com").password("pw").build();
when(authentication.isAuthenticated()).thenReturn(true);
when(authentication.getName()).thenReturn("user@example.com");
when(userService.findByEmail("user@example.com")).thenReturn(user);
UUID result = SecurityUtils.requireUserId(authentication, userService);
assertThat(result).isEqualTo(userId);
}
}

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

@@ -487,6 +487,7 @@ class TranscriptionServiceTest {
org.mockito.ArgumentMatchers.eq(docId),
payloadCaptor.capture());
assertThat(payloadCaptor.getValue()).containsEntry("pageNumber", 3);
assertThat(payloadCaptor.getValue()).containsEntry("blockId", blockId.toString());
}
@Test

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",
@@ -706,5 +707,47 @@
"admin_new_invite_expires": "Ablaufdatum (optional)",
"admin_invite_created_title": "Einladung erstellt",
"admin_invite_created_desc": "Teile diesen Link mit der einzuladenden Person:",
"admin_invite_revoke_confirm": "Einladung wirklich widerrufen?"
"admin_invite_revoke_confirm": "Einladung wirklich widerrufen?",
"greeting_morning": "Guten Morgen, {name}.",
"greeting_day": "Hallo, {name}.",
"greeting_evening": "Guten Abend, {name}.",
"dashboard_resume_label": "Weiter, wo du aufgehört hast",
"dashboard_blocks": "{count} Abschnitte",
"dashboard_resume_cta": "Weitertranskribieren",
"dashboard_resume_other": "oder anderen Brief wählen",
"dashboard_empty_title": "Noch kein Dokument begonnen",
"dashboard_empty_body": "Wähle ein Dokument aus dem Archiv, um mit der Transkription zu beginnen.",
"dashboard_empty_cta": "Zum Archiv",
"dashboard_mission_caption": "Offene Aufgaben",
"queue_segment": "Segmentieren",
"queue_segment_blurb": "Seiten aufteilen",
"queue_transcribe": "Transkribieren",
"queue_transcribe_blurb": "Text erfassen",
"queue_review": "Prüfen",
"queue_review_blurb": "Texte kontrollieren",
"queue_n_open": "{n} offen",
"queue_show_all": "Alle anzeigen →",
"pulse_eyebrow": "Diese Woche",
"pulse_headline": "Ihr habt {pages} Seiten bearbeitet.",
"pulse_you": "Du selbst hast {pages} davon bearbeitet.",
"pulse_contributors": "Mitwirkende",
"pulse_transcribed": "Textstellen markiert",
"pulse_reviewed": "Textstellen transkribiert",
"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",
"audit_action_file_uploaded": "hat eine Datei hochgeladen:",
"audit_action_annotation_created": "hat eine Markierung erstellt in",
"audit_action_comment_added": "hat kommentiert:",
"audit_action_mention_created": "hat dich erwähnt in",
"dropzone_release": "Loslassen zum Hochladen"
}

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",
@@ -706,5 +707,47 @@
"admin_new_invite_expires": "Expiry date (optional)",
"admin_invite_created_title": "Invite created",
"admin_invite_created_desc": "Share this link with the person you are inviting:",
"admin_invite_revoke_confirm": "Really revoke this invite?"
"admin_invite_revoke_confirm": "Really revoke this invite?",
"greeting_morning": "Good morning, {name}.",
"greeting_day": "Hello, {name}.",
"greeting_evening": "Good evening, {name}.",
"dashboard_resume_label": "Continue where you left off",
"dashboard_blocks": "{count} sections",
"dashboard_resume_cta": "Continue transcribing",
"dashboard_resume_other": "or choose another document",
"dashboard_empty_title": "No document started yet",
"dashboard_empty_body": "Choose a document from the archive to start transcribing.",
"dashboard_empty_cta": "To the archive",
"dashboard_mission_caption": "Open tasks",
"queue_segment": "Segment",
"queue_segment_blurb": "Split pages",
"queue_transcribe": "Transcribe",
"queue_transcribe_blurb": "Capture text",
"queue_review": "Review",
"queue_review_blurb": "Check texts",
"queue_n_open": "{n} open",
"queue_show_all": "Show all →",
"pulse_eyebrow": "This week",
"pulse_headline": "You have worked on {pages} pages.",
"pulse_you": "You personally worked on {pages} of them.",
"pulse_contributors": "Contributors",
"pulse_transcribed": "Passages annotated",
"pulse_reviewed": "Passages transcribed",
"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",
"audit_action_file_uploaded": "uploaded a file:",
"audit_action_annotation_created": "created an annotation in",
"audit_action_comment_added": "commented:",
"audit_action_mention_created": "mentioned you in",
"dropzone_release": "Release to upload"
}

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",
@@ -706,5 +707,47 @@
"admin_new_invite_expires": "Fecha de vencimiento (opcional)",
"admin_invite_created_title": "Invitación creada",
"admin_invite_created_desc": "Comparte este enlace con la persona invitada:",
"admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?"
"admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?",
"greeting_morning": "Buenos días, {name}.",
"greeting_day": "Hola, {name}.",
"greeting_evening": "Buenas noches, {name}.",
"dashboard_resume_label": "Continuar donde lo dejaste",
"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",
"dashboard_empty_body": "Elige un documento del archivo para empezar a transcribir.",
"dashboard_empty_cta": "Al archivo",
"dashboard_mission_caption": "Tareas pendientes",
"queue_segment": "Segmentar",
"queue_segment_blurb": "Dividir páginas",
"queue_transcribe": "Transcribir",
"queue_transcribe_blurb": "Capturar texto",
"queue_review": "Revisar",
"queue_review_blurb": "Controlar textos",
"queue_n_open": "{n} pendiente",
"queue_show_all": "Ver todo →",
"pulse_eyebrow": "Esta semana",
"pulse_headline": "Habéis trabajado {pages} páginas.",
"pulse_you": "Tú mismo has trabajado {pages} de ellas.",
"pulse_contributors": "Colaboradores",
"pulse_transcribed": "Fragmentos anotados",
"pulse_reviewed": "Fragmentos transcritos",
"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",
"audit_action_file_uploaded": "subió un archivo:",
"audit_action_annotation_created": "creó una anotación en",
"audit_action_comment_added": "comentó:",
"audit_action_mention_created": "te mencionó en",
"dropzone_release": "Suelta para subir"
}

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

@@ -324,6 +324,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/invites": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listInvites"];
put?: never;
post: operations["createInvite"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/groups": {
parameters: {
query?: never;
@@ -356,6 +372,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/documents/{id}/file": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getDocumentFile"];
put?: never;
post: operations["attachFile"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{documentId}/transcription-blocks": {
parameters: {
query?: never;
@@ -532,6 +564,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/auth/register": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["register"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/auth/forgot-password": {
parameters: {
query?: never;
@@ -1044,22 +1092,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/documents/{id}/file": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getDocumentFile"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{documentId}/transcription-blocks/{blockId}/history": {
parameters: {
query?: never;
@@ -1108,38 +1140,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/documents/recent-activity": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getRecentActivity"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/incomplete": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getIncomplete"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/incomplete/next": {
parameters: {
query?: never;
@@ -1188,6 +1188,70 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/dashboard/resume": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getResume"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/dashboard/pulse": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getPulse"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/dashboard/activity": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getActivity"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/auth/invite/{code}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getInvitePrefill"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/import-status": {
parameters: {
query?: never;
@@ -1236,6 +1300,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/invites/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["revokeInvite"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -1253,12 +1333,13 @@ export interface components {
AppUser: {
/** Format: uuid */
id: string;
/** Format: email */
email: string;
password?: string;
firstName?: string;
lastName?: string;
/** Format: date */
birthDate?: string;
email: string;
contact?: string;
enabled: boolean;
notifyOnReply: boolean;
@@ -1266,6 +1347,7 @@ export interface components {
groups: components["schemas"]["UserGroup"][];
/** Format: date-time */
createdAt: string;
color: string;
};
UserGroup: {
/** Format: uuid */
@@ -1405,6 +1487,7 @@ export interface components {
blockIds?: string[];
};
CreateUserRequest: {
/** Format: email */
email: string;
initialPassword?: string;
groupIds?: string[];
@@ -1470,11 +1553,40 @@ export interface components {
};
TriggerSenderTrainingDTO: {
/** Format: uuid */
personId?: string;
personId: string;
};
BatchOcrDTO: {
documentIds: string[];
};
CreateInviteRequest: {
label?: string;
/** Format: int32 */
maxUses?: number;
prefillFirstName?: string;
prefillLastName?: string;
prefillEmail?: string;
groupIds?: string[];
/** Format: date-time */
expiresAt?: string;
};
InviteListItemDTO: {
/** Format: uuid */
id: string;
code: string;
displayCode: string;
label?: string;
/** Format: int32 */
useCount: number;
/** Format: int32 */
maxUses?: number;
/** Format: date-time */
expiresAt?: string;
revoked: boolean;
status: string;
/** Format: date-time */
createdAt: string;
shareableUrl?: string;
};
GroupDTO: {
name?: string;
permissions?: string[];
@@ -1580,6 +1692,15 @@ export interface components {
token?: string;
newPassword?: string;
};
RegisterRequest: {
code: string;
/** Format: email */
email: string;
password: string;
firstName?: string;
lastName?: string;
notifyOnMention?: boolean;
};
ForgotPasswordRequest: {
email?: string;
};
@@ -1633,6 +1754,11 @@ export interface components {
/** Format: int64 */
transcriptionCount: number;
};
ActivityActorDTO: {
initials: string;
color: string;
name?: string;
};
TranscriptionQueueItemDTO: {
/** Format: uuid */
id: string;
@@ -1645,6 +1771,8 @@ export interface components {
textedBlockCount: number;
/** Format: int32 */
reviewedBlockCount: number;
contributors: components["schemas"]["ActivityActorDTO"][];
hasMoreContributors: boolean;
};
TagTreeNodeDTO: {
/** Format: uuid */
@@ -1754,6 +1882,8 @@ export interface components {
/** Format: int64 */
totalElements?: number;
pageable?: components["schemas"]["PageableObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */
size?: number;
content?: components["schemas"]["NotificationDTO"][];
@@ -1762,8 +1892,6 @@ export interface components {
sort?: components["schemas"]["SortObject"];
/** Format: int32 */
numberOfElements?: number;
first?: boolean;
last?: boolean;
empty?: boolean;
};
PageableObject: {
@@ -1847,10 +1975,47 @@ export interface components {
summarySnippet?: string;
summaryOffsets: components["schemas"]["MatchOffset"][];
};
IncompleteDocumentDTO: {
DashboardResumeDTO: {
/** Format: uuid */
id: string;
documentId: string;
title: string;
caption: string;
excerpt: string;
/** Format: int32 */
totalBlocks: number;
/** Format: int32 */
pct: number;
thumbnailUrl?: string;
collaborators: components["schemas"]["ActivityActorDTO"][];
};
DashboardPulseDTO: {
/** Format: int32 */
pages: number;
/** Format: int32 */
annotated: number;
/** Format: int32 */
transcribed: number;
/** Format: int32 */
uploaded: number;
/** Format: int32 */
yourPages: number;
contributors: components["schemas"]["ActivityActorDTO"][];
};
ActivityFeedItemDTO: {
/** @enum {string} */
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED";
actor?: components["schemas"]["ActivityActorDTO"];
/** Format: uuid */
documentId: string;
documentTitle: string;
/** Format: date-time */
happenedAt: string;
youMentioned: boolean;
};
InvitePrefillDTO: {
firstName: string;
lastName: string;
email: string;
};
};
responses: never;
@@ -2619,6 +2784,52 @@ export interface operations {
};
};
};
listInvites: {
parameters: {
query?: {
status?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["InviteListItemDTO"][];
};
};
};
};
createInvite: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateInviteRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["InviteListItemDTO"];
};
};
};
};
getAllGroups: {
parameters: {
query?: never;
@@ -2687,6 +2898,57 @@ export interface operations {
};
};
};
getDocumentFile: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": string;
};
};
};
};
attachFile: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: {
content: {
"multipart/form-data": {
/** Format: binary */
file: string;
};
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Document"];
};
};
};
};
listBlocks: {
parameters: {
query?: never;
@@ -3086,6 +3348,30 @@ export interface operations {
};
};
};
register: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RegisterRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["AppUser"];
};
};
};
};
forgotPassword: {
parameters: {
query?: never;
@@ -3603,9 +3889,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": {
[key: string]: unknown;
};
"*/*": components["schemas"]["TrainingInfoResponse"];
};
};
};
@@ -3850,28 +4134,6 @@ export interface operations {
};
};
};
getDocumentFile: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": string;
};
};
};
};
getBlockHistory: {
parameters: {
query?: never;
@@ -3953,51 +4215,6 @@ export interface operations {
};
};
};
getRecentActivity: {
parameters: {
query?: {
size?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Document"][];
};
};
};
};
getIncomplete: {
parameters: {
query?: {
/** @description Maximum number of results */
size?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["IncompleteDocumentDTO"][];
};
};
};
};
getNextIncomplete: {
parameters: {
query: {
@@ -4068,6 +4285,90 @@ export interface operations {
};
};
};
getResume: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DashboardResumeDTO"];
};
};
};
};
getPulse: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DashboardPulseDTO"];
};
};
};
};
getActivity: {
parameters: {
query?: {
limit?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ActivityFeedItemDTO"][];
};
};
};
};
getInvitePrefill: {
parameters: {
query?: never;
header?: never;
path: {
code: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["InvitePrefillDTO"];
};
};
};
};
importStatus: {
parameters: {
query?: never;
@@ -4129,4 +4430,28 @@ export interface operations {
};
};
};
revokeInvite: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
}
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();
});
});