13 Commits

Author SHA1 Message Date
2696195e74 Merge pull request 'feat: add task drag and drop' (#10) from feature/006-task-drag-and-drop into main
Reviewed-on: #10
2026-07-27 13:18:19 +02:00
c3c64482c0 feat: add task drag and drop 2026-07-27 13:15:39 +02:00
ddd706536e Update docs/roadmap.md 2026-07-27 10:44:41 +02:00
dd145db12f Update docs/features/005-task-status.md 2026-07-27 10:39:22 +02:00
5b9e562722 Merge pull request 'feat: add task status transitions' (#9) from feature/005-task-status into main
Reviewed-on: #9
2026-07-27 00:48:44 +02:00
65a6488c0b feat: add task status transitions 2026-07-27 00:46:37 +02:00
6570aad4a2 Merge pull request 'docs: align completed feature status' (#8) from chore/005-align-feature-status into main
Reviewed-on: #8
2026-07-26 23:57:51 +02:00
85afc3d3a3 docs: align completed feature status 2026-07-26 23:56:58 +02:00
d78611f5f7 Merge pull request 'feat: add task assignment' (#7) from feature/004-task-assignment into main
Reviewed-on: #7
2026-07-26 22:50:09 +02:00
aaebe888f3 feat: add task assignment 2026-07-26 22:49:06 +02:00
2e62261f49 Merge pull request 'feat: add task points' (#6) from feature/003-task-points into main
Reviewed-on: #6
2026-07-26 17:45:58 +02:00
059d4da921 feat: add task points 2026-07-26 17:43:07 +02:00
1654b54a22 Merge pull request 'docs: specify task points feature' (#5) from docs/003-task-points into main
Reviewed-on: #5
2026-07-26 16:41:52 +02:00
39 changed files with 3141 additions and 122 deletions

View File

@ -6,11 +6,13 @@ innehåller två separata applikationer:
- en backend byggd med Java 21, Spring Boot och Maven - en backend byggd med Java 21, Spring Boot och Maven
- en frontend byggd med React, TypeScript, Vite och pnpm - en frontend byggd med React, TypeScript, Vite och pnpm
Backend använder en lokal filbaserad H2-databas i `backend/data`. Databasschemat Backend använder en lokal H2-databas i minnet. Databasschemat hanteras med
hanteras med Flyway. Databasfilerna är lokala och ignoreras av Git. Flyway, och lokal utvecklingsdata återställs när backend startas om.
API:t innehåller endpoints under `/api/users` för användare och `/api/tasks` för API:t innehåller endpoints under `/api/users` för användare och `/api/tasks` för
att skapa och lista gemensamma hushållsuppgifter. att skapa, lista, tilldela och ändra status på gemensamma hushållsuppgifter.
Uppgiftskort kan flyttas mellan brädans statuskolumner med drag-and-drop eller
med de befintliga statusknapparna.
## Starta backend ## Starta backend

View File

@ -2,10 +2,16 @@ package se.rubble.hemhub.api;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import se.rubble.hemhub.task.InvalidTaskException; import se.rubble.hemhub.task.InvalidTaskException;
import se.rubble.hemhub.task.InvalidTaskAssignmentException;
import se.rubble.hemhub.task.InvalidTaskStatusException;
import se.rubble.hemhub.task.AssigneeNotFoundException;
import se.rubble.hemhub.task.TaskNotFoundException;
import se.rubble.hemhub.task.TaskRequiresAssigneeException;
import se.rubble.hemhub.user.InvalidUserNameException; import se.rubble.hemhub.user.InvalidUserNameException;
import se.rubble.hemhub.user.UserNameAlreadyExistsException; import se.rubble.hemhub.user.UserNameAlreadyExistsException;
@ -33,5 +39,47 @@ public class ApiExceptionHandler {
return ResponseEntity.badRequest() return ResponseEntity.badRequest()
.body(new ApiError("INVALID_TASK", exception.getMessage())); .body(new ApiError("INVALID_TASK", exception.getMessage()));
} }
}
@ExceptionHandler(InvalidTaskAssignmentException.class)
public ResponseEntity<ApiError> handleInvalidTaskAssignment(
InvalidTaskAssignmentException exception) {
return ResponseEntity.badRequest()
.body(new ApiError("INVALID_TASK_ASSIGNMENT", exception.getMessage()));
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiError> handleInvalidPathParameter() {
return ResponseEntity.badRequest()
.body(new ApiError(
"INVALID_TASK_ASSIGNMENT",
"Uppgifts-id måste vara ett giltigt UUID."));
}
@ExceptionHandler(TaskNotFoundException.class)
public ResponseEntity<ApiError> handleTaskNotFound() {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiError("TASK_NOT_FOUND", "Uppgiften finns inte."));
}
@ExceptionHandler(AssigneeNotFoundException.class)
public ResponseEntity<ApiError> handleAssigneeNotFound() {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiError("USER_NOT_FOUND", "Användaren finns inte."));
}
@ExceptionHandler(InvalidTaskStatusException.class)
public ResponseEntity<ApiError> handleInvalidTaskStatus() {
return ResponseEntity.badRequest()
.body(new ApiError(
"INVALID_TASK_STATUS",
"Status måste vara WAITING, IN_PROGRESS eller COMPLETED."));
}
@ExceptionHandler(TaskRequiresAssigneeException.class)
public ResponseEntity<ApiError> handleTaskRequiresAssignee() {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ApiError(
"TASK_REQUIRES_ASSIGNEE",
"En pågående uppgift måste ha en ansvarig."));
}
}

View File

@ -0,0 +1,4 @@
package se.rubble.hemhub.task;
public class AssigneeNotFoundException extends RuntimeException {
}

View File

@ -1,5 +1,22 @@
package se.rubble.hemhub.task; package se.rubble.hemhub.task;
public record CreateTaskRequest(String title, String description) { import tools.jackson.databind.JsonNode;
}
public record CreateTaskRequest(
String title,
String description,
JsonNode points,
JsonNode assigneeId) {
Integer integerPoints() {
if (points == null || !points.isIntegralNumber() || !points.canConvertToInt()) {
return null;
}
return points.intValue();
}
UUIDValue parsedAssigneeId() {
return UUIDValue.optional(assigneeId);
}
}

View File

@ -0,0 +1,8 @@
package se.rubble.hemhub.task;
public class InvalidTaskAssignmentException extends RuntimeException {
public InvalidTaskAssignmentException(String message) {
super(message);
}
}

View File

@ -0,0 +1,4 @@
package se.rubble.hemhub.task;
public class InvalidTaskStatusException extends RuntimeException {
}

View File

@ -8,7 +8,11 @@ import jakarta.persistence.Entity;
import jakarta.persistence.EnumType; import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated; import jakarta.persistence.Enumerated;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.FetchType;
import se.rubble.hemhub.user.User;
@Entity @Entity
@Table(name = "task") @Table(name = "task")
@ -27,6 +31,13 @@ class Task {
@Column(nullable = false, length = 20) @Column(nullable = false, length = 20)
private TaskStatus status; private TaskStatus status;
@Column(nullable = false)
private int points;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "assignee_id")
private User assignee;
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private Instant createdAt; private Instant createdAt;
@ -38,11 +49,23 @@ class Task {
String title, String title,
String description, String description,
TaskStatus status, TaskStatus status,
int points,
User assignee,
Instant createdAt) { Instant createdAt) {
if (points < 1 || points > 99) {
throw new InvalidTaskException(
"Poäng måste vara ett heltal mellan 1 och 99.");
}
if (status == TaskStatus.IN_PROGRESS && assignee == null) {
throw new TaskRequiresAssigneeException();
}
this.id = id; this.id = id;
this.title = title; this.title = title;
this.description = description; this.description = description;
this.status = status; this.status = status;
this.points = points;
this.assignee = assignee;
this.createdAt = createdAt; this.createdAt = createdAt;
} }
@ -62,8 +85,34 @@ class Task {
return status; return status;
} }
int getPoints() {
return points;
}
User getAssignee() {
return assignee;
}
void changeAssignee(User assignee) {
if (status == TaskStatus.IN_PROGRESS && assignee == null) {
throw new TaskRequiresAssigneeException();
}
this.assignee = assignee;
}
void changeStatus(TaskStatus targetStatus, User automaticAssignee) {
if (targetStatus == TaskStatus.IN_PROGRESS && assignee == null) {
if (automaticAssignee == null) {
throw new TaskRequiresAssigneeException();
}
assignee = automaticAssignee;
}
status = targetStatus;
}
Instant getCreatedAt() { Instant getCreatedAt() {
return createdAt; return createdAt;
} }
} }

View File

@ -1,10 +1,13 @@
package se.rubble.hemhub.task; package se.rubble.hemhub.task;
import java.util.List; import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.ResponseStatus;
@ -28,9 +31,38 @@ public class TaskController {
@PostMapping @PostMapping
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
public TaskResponse create(@RequestBody(required = false) CreateTaskRequest request) { public TaskResponse create(@RequestBody(required = false) CreateTaskRequest request) {
UUIDValue assigneeId = request == null
? UUIDValue.optional(null)
: request.parsedAssigneeId();
return taskService.create( return taskService.create(
request == null ? null : request.title(), request == null ? null : request.title(),
request == null ? null : request.description()); request == null ? null : request.description(),
request == null ? null : request.integerPoints(),
assigneeId.value());
}
@PutMapping("/{taskId}/assignee")
public TaskResponse updateAssignee(
@PathVariable UUID taskId,
@RequestBody(required = false) UpdateTaskAssigneeRequest request) {
if (request == null) {
throw new InvalidTaskAssignmentException("Fältet assigneeId måste anges.");
}
return taskService.updateAssignee(taskId, request.parsedAssigneeId().value());
}
@PutMapping("/{taskId}/status")
public TaskResponse updateStatus(
@PathVariable UUID taskId,
@RequestBody(required = false) UpdateTaskStatusRequest request) {
if (request == null) {
throw new InvalidTaskStatusException();
}
return taskService.updateStatus(
taskId,
request.parsedStatus(),
request.parsedActiveUserId().value());
} }
} }

View File

@ -0,0 +1,4 @@
package se.rubble.hemhub.task;
public class TaskNotFoundException extends RuntimeException {
}

View File

@ -4,9 +4,13 @@ import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.EntityGraph;
interface TaskRepository extends JpaRepository<Task, UUID> { interface TaskRepository extends JpaRepository<Task, UUID> {
@EntityGraph(attributePaths = "assignee")
List<Task> findAllByOrderByCreatedAtAscIdAsc(); List<Task> findAllByOrderByCreatedAtAscIdAsc();
}
@EntityGraph(attributePaths = "assignee")
java.util.Optional<Task> findOneById(UUID id);
}

View File

@ -0,0 +1,4 @@
package se.rubble.hemhub.task;
public class TaskRequiresAssigneeException extends RuntimeException {
}

View File

@ -8,6 +8,8 @@ public record TaskResponse(
String title, String title,
String description, String description,
TaskStatus status, TaskStatus status,
int points,
AssigneeResponse assignee,
Instant createdAt) { Instant createdAt) {
static TaskResponse from(Task task) { static TaskResponse from(Task task) {
@ -16,7 +18,15 @@ public record TaskResponse(
task.getTitle(), task.getTitle(),
task.getDescription(), task.getDescription(),
task.getStatus(), task.getStatus(),
task.getPoints(),
AssigneeResponse.from(task.getAssignee()),
task.getCreatedAt()); task.getCreatedAt());
} }
}
public record AssigneeResponse(UUID id, String name) {
static AssigneeResponse from(se.rubble.hemhub.user.User user) {
return user == null ? null : new AssigneeResponse(user.getId(), user.getName());
}
}
}

View File

@ -9,19 +9,24 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import se.rubble.hemhub.user.User;
import se.rubble.hemhub.user.UserRepository;
@Service @Service
class TaskService { class TaskService {
private final TaskRepository taskRepository; private final TaskRepository taskRepository;
private final UserRepository userRepository;
private final Clock clock; private final Clock clock;
@Autowired @Autowired
TaskService(TaskRepository taskRepository) { TaskService(TaskRepository taskRepository, UserRepository userRepository) {
this(taskRepository, Clock.systemUTC()); this(taskRepository, userRepository, Clock.systemUTC());
} }
TaskService(TaskRepository taskRepository, Clock clock) { TaskService(TaskRepository taskRepository, UserRepository userRepository, Clock clock) {
this.taskRepository = taskRepository; this.taskRepository = taskRepository;
this.userRepository = userRepository;
this.clock = clock; this.clock = clock;
} }
@ -33,7 +38,11 @@ class TaskService {
} }
@Transactional @Transactional
TaskResponse create(String requestedTitle, String requestedDescription) { TaskResponse create(
String requestedTitle,
String requestedDescription,
Integer requestedPoints,
UUID requestedAssigneeId) {
String title = requestedTitle == null ? "" : requestedTitle.trim(); String title = requestedTitle == null ? "" : requestedTitle.trim();
String description = normalizeDescription(requestedDescription); String description = normalizeDescription(requestedDescription);
@ -47,16 +56,63 @@ class TaskService {
"Beskrivningen får innehålla högst 500 tecken."); "Beskrivningen får innehålla högst 500 tecken.");
} }
if (requestedPoints == null) {
throw new InvalidTaskException(
"Poäng måste vara ett heltal mellan 1 och 99.");
}
User assignee = findAssignee(requestedAssigneeId);
Task task = new Task( Task task = new Task(
UUID.randomUUID(), UUID.randomUUID(),
title, title,
description, description,
TaskStatus.WAITING, TaskStatus.WAITING,
requestedPoints,
assignee,
Instant.now(clock)); Instant.now(clock));
return TaskResponse.from(taskRepository.save(task)); return TaskResponse.from(taskRepository.save(task));
} }
@Transactional
TaskResponse updateAssignee(UUID taskId, UUID requestedAssigneeId) {
Task task = taskRepository.findOneById(taskId)
.orElseThrow(TaskNotFoundException::new);
User assignee = findAssignee(requestedAssigneeId);
task.changeAssignee(assignee);
return TaskResponse.from(task);
}
@Transactional
TaskResponse updateStatus(
UUID taskId,
TaskStatus targetStatus,
UUID activeUserId) {
Task task = taskRepository.findOneById(taskId)
.orElseThrow(TaskNotFoundException::new);
User automaticAssignee = null;
if (targetStatus == TaskStatus.IN_PROGRESS && task.getAssignee() == null) {
if (activeUserId == null) {
throw new TaskRequiresAssigneeException();
}
automaticAssignee = findAssignee(activeUserId);
}
task.changeStatus(targetStatus, automaticAssignee);
return TaskResponse.from(task);
}
private User findAssignee(UUID requestedAssigneeId) {
if (requestedAssigneeId == null) {
return null;
}
return userRepository.findById(requestedAssigneeId)
.orElseThrow(AssigneeNotFoundException::new);
}
private static String normalizeDescription(String requestedDescription) { private static String normalizeDescription(String requestedDescription) {
if (requestedDescription == null) { if (requestedDescription == null) {
return null; return null;
@ -70,4 +126,3 @@ class TaskService {
return value.codePointCount(0, value.length()); return value.codePointCount(0, value.length());
} }
} }

View File

@ -0,0 +1,25 @@
package se.rubble.hemhub.task;
import java.util.UUID;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.JsonNodeType;
record UUIDValue(boolean present, UUID value) {
static UUIDValue optional(JsonNode node) {
if (node == null || node.isNull()) {
return new UUIDValue(node != null, null);
}
if (node.getNodeType() != JsonNodeType.STRING) {
throw new InvalidTaskAssignmentException("Användar-id måste vara ett giltigt UUID.");
}
try {
return new UUIDValue(true, UUID.fromString(node.stringValue()));
} catch (IllegalArgumentException exception) {
throw new InvalidTaskAssignmentException("Användar-id måste vara ett giltigt UUID.");
}
}
}

View File

@ -0,0 +1,16 @@
package se.rubble.hemhub.task;
import tools.jackson.databind.JsonNode;
public record UpdateTaskAssigneeRequest(JsonNode assigneeId) {
UUIDValue parsedAssigneeId() {
UUIDValue parsed = UUIDValue.optional(assigneeId);
if (!parsed.present()) {
throw new InvalidTaskAssignmentException("Fältet assigneeId måste anges.");
}
return parsed;
}
}

View File

@ -0,0 +1,23 @@
package se.rubble.hemhub.task;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.JsonNodeType;
public record UpdateTaskStatusRequest(JsonNode status, JsonNode activeUserId) {
TaskStatus parsedStatus() {
if (status == null || status.getNodeType() != JsonNodeType.STRING) {
throw new InvalidTaskStatusException();
}
try {
return TaskStatus.valueOf(status.stringValue());
} catch (IllegalArgumentException exception) {
throw new InvalidTaskStatusException();
}
}
UUIDValue parsedActiveUserId() {
return UUIDValue.optional(activeUserId);
}
}

View File

@ -10,7 +10,7 @@ import jakarta.persistence.Table;
@Entity @Entity
@Table(name = "app_user") @Table(name = "app_user")
class User { public class User {
@Id @Id
private UUID id; private UUID id;
@ -34,11 +34,11 @@ class User {
this.createdAt = createdAt; this.createdAt = createdAt;
} }
UUID getId() { public UUID getId() {
return id; return id;
} }
String getName() { public String getName() {
return name; return name;
} }

View File

@ -4,8 +4,7 @@ import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
interface UserRepository extends JpaRepository<User, UUID> { public interface UserRepository extends JpaRepository<User, UUID> {
boolean existsByNormalizedName(String normalizedName); boolean existsByNormalizedName(String normalizedName);
} }

View File

@ -1,7 +1,6 @@
spring.datasource.url=jdbc:h2:file:./data/hemhub;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH spring.datasource.url=jdbc:h2:mem:hemhub;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1
spring.datasource.username=sa spring.datasource.username=sa
spring.datasource.password= spring.datasource.password=
spring.jpa.hibernate.ddl-auto=validate spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false spring.jpa.open-in-view=false
spring.flyway.enabled=true spring.flyway.enabled=true

View File

@ -0,0 +1,8 @@
ALTER TABLE task ADD COLUMN points INTEGER;
UPDATE task SET points = 1 WHERE points IS NULL;
ALTER TABLE task ALTER COLUMN points SET NOT NULL;
ALTER TABLE task
ADD CONSTRAINT ck_task_points_range CHECK (points BETWEEN 1 AND 99);

View File

@ -0,0 +1,6 @@
ALTER TABLE task
ADD COLUMN assignee_id UUID;
ALTER TABLE task
ADD CONSTRAINT fk_task_assignee
FOREIGN KEY (assignee_id) REFERENCES app_user (id);

View File

@ -9,11 +9,13 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext; import org.springframework.web.context.WebApplicationContext;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -41,7 +43,8 @@ class TaskApiTest {
.content(""" .content("""
{ {
"title": " Dammsuga ", "title": " Dammsuga ",
"description": " Bottenvåningen " "description": " Bottenvåningen ",
"points": 7
} }
""")) """))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
@ -49,7 +52,74 @@ class TaskApiTest {
.andExpect(jsonPath("$.title").value("Dammsuga")) .andExpect(jsonPath("$.title").value("Dammsuga"))
.andExpect(jsonPath("$.description").value("Bottenvåningen")) .andExpect(jsonPath("$.description").value("Bottenvåningen"))
.andExpect(jsonPath("$.status").value("WAITING")) .andExpect(jsonPath("$.status").value("WAITING"))
.andExpect(jsonPath("$.points").value(7))
.andExpect(jsonPath("$.assignee").value((Object) null))
.andExpect(jsonPath("$.createdAt").isString()); .andExpect(jsonPath("$.createdAt").isString());
mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].points").value(7));
}
@Test
void createsUnassignedTaskWhenAssigneeIsExplicitlyNull() throws Exception {
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"title": "Dammsuga", "points": 1, "assigneeId": null}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.assignee").value((Object) null));
}
@Test
void createsAndListsTaskWithAssignee() throws Exception {
UUID userId = createUser("Anna");
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "Dammsuga",
"points": 7,
"assigneeId": "%s"
}
""".formatted(userId)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.assignee.id").value(userId.toString()))
.andExpect(jsonPath("$.assignee.name").value("Anna"));
mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].assignee.id").value(userId.toString()))
.andExpect(jsonPath("$[0].assignee.name").value("Anna"));
}
@Test
void rejectsUnknownAssigneeWhenCreatingTask() throws Exception {
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "Dammsuga",
"points": 7,
"assigneeId": "00000000-0000-0000-0000-000000000099"
}
"""))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("USER_NOT_FOUND"));
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "Dammsuga",
"points": 7,
"assigneeId": "inte-ett-uuid"
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT"));
} }
@Test @Test
@ -57,7 +127,7 @@ class TaskApiTest {
mockMvc.perform(post("/api/tasks") mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(""" .content("""
{"title": "Dammsuga", "description": " "} {"title": "Dammsuga", "description": " ", "points": 1}
""")) """))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
.andExpect(jsonPath("$.description").value((Object) null)); .andExpect(jsonPath("$.description").value((Object) null));
@ -66,18 +136,58 @@ class TaskApiTest {
@Test @Test
void rejectsBlankAndTooLongTitles() throws Exception { void rejectsBlankAndTooLongTitles() throws Exception {
assertInvalidTask(""" assertInvalidTask("""
{"title": " "} {"title": " ", "points": 1}
"""); """);
assertInvalidTask("{\"title\": \"%s\"}".formatted("a".repeat(101))); assertInvalidTask(
"{\"title\": \"%s\", \"points\": 1}".formatted("a".repeat(101)));
} }
@Test @Test
void rejectsTooLongDescription() throws Exception { void rejectsTooLongDescription() throws Exception {
assertInvalidTask(""" assertInvalidTask("""
{"title": "Dammsuga", "description": "%s"} {"title": "Dammsuga", "description": "%s", "points": 1}
""".formatted("a".repeat(501))); """.formatted("a".repeat(501)));
} }
@Test
void acceptsPointBoundaries() throws Exception {
createTaskWithPoints(1)
.andExpect(status().isCreated())
.andExpect(jsonPath("$.points").value(1));
createTaskWithPoints(99)
.andExpect(status().isCreated())
.andExpect(jsonPath("$.points").value(99));
}
@Test
void rejectsMissingNullAndOutOfRangePoints() throws Exception {
assertInvalidTask("""
{"title": "Saknas"}
""");
assertInvalidTask("""
{"title": "Null", "points": null}
""");
assertInvalidTask("""
{"title": "Noll", "points": 0}
""");
assertInvalidTask("""
{"title": "Negativ", "points": -1}
""");
assertInvalidTask("""
{"title": "För stor", "points": 100}
""");
}
@Test
void rejectsNonIntegerPoints() throws Exception {
assertInvalidTask("""
{"title": "Decimal", "points": 1.5}
""");
assertInvalidTask("""
{"title": "Text", "points": "sju"}
""");
}
@Test @Test
void listsTasksOldestFirstWithIdAsTieBreaker() throws Exception { void listsTasksOldestFirstWithIdAsTieBreaker() throws Exception {
Instant older = Instant.parse("2026-07-24T10:00:00Z"); Instant older = Instant.parse("2026-07-24T10:00:00Z");
@ -86,17 +196,96 @@ class TaskApiTest {
UUID secondId = UUID.fromString("00000000-0000-0000-0000-000000000002"); UUID secondId = UUID.fromString("00000000-0000-0000-0000-000000000002");
UUID newestId = UUID.fromString("00000000-0000-0000-0000-000000000003"); UUID newestId = UUID.fromString("00000000-0000-0000-0000-000000000003");
taskRepository.save(new Task(newestId, "Nyast", null, TaskStatus.WAITING, newer)); taskRepository.save(new Task(
taskRepository.save(new Task(secondId, "Andra", null, TaskStatus.IN_PROGRESS, older)); newestId, "Nyast", null, TaskStatus.WAITING, 3, null, newer));
taskRepository.save(new Task(firstId, "Första", null, TaskStatus.COMPLETED, older)); taskRepository.save(new Task(
secondId, "Andra", null, TaskStatus.WAITING, 2, null, older));
taskRepository.save(new Task(
firstId, "Första", null, TaskStatus.COMPLETED, 1, null, older));
mockMvc.perform(get("/api/tasks")) mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value("Första")) .andExpect(jsonPath("$[0].title").value("Första"))
.andExpect(jsonPath("$[0].points").value(1))
.andExpect(jsonPath("$[1].title").value("Andra")) .andExpect(jsonPath("$[1].title").value("Andra"))
.andExpect(jsonPath("$[2].title").value("Nyast")); .andExpect(jsonPath("$[2].title").value("Nyast"));
} }
@Test
void assignsChangesAndRemovesAssigneeWithoutChangingOtherTaskFields() throws Exception {
UUID firstUserId = createUser("Bo");
UUID secondUserId = createUser("Cecilia");
String taskId = createTask("Dammsuga", "Bottenvåningen", 7, null);
updateAssignee(taskId, """
{"assigneeId": "%s"}
""".formatted(firstUserId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.assignee.id").value(firstUserId.toString()))
.andExpect(jsonPath("$.status").value("WAITING"))
.andExpect(jsonPath("$.title").value("Dammsuga"))
.andExpect(jsonPath("$.description").value("Bottenvåningen"))
.andExpect(jsonPath("$.points").value(7));
updateAssignee(taskId, """
{"assigneeId": "%s"}
""".formatted(secondUserId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.assignee.id").value(secondUserId.toString()));
updateAssignee(taskId, """
{"assigneeId": null}
""")
.andExpect(status().isOk())
.andExpect(jsonPath("$.assignee").value((Object) null))
.andExpect(jsonPath("$.status").value("WAITING"))
.andExpect(jsonPath("$.title").value("Dammsuga"))
.andExpect(jsonPath("$.description").value("Bottenvåningen"))
.andExpect(jsonPath("$.points").value(7));
}
@Test
void rejectsMissingAssigneeFieldAndInvalidUuid() throws Exception {
String taskId = createTask("Dammsuga", null, 1, null);
updateAssignee(taskId, "{}")
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT"));
updateAssignee(taskId, """
{"assigneeId": "inte-ett-uuid"}
""")
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT"));
updateAssignee("inte-ett-uuid", """
{"assigneeId": null}
""")
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT"));
}
@Test
void returnsNotFoundForUnknownTaskAndUnknownAssignee() throws Exception {
String taskId = createTask("Dammsuga", null, 1, null);
updateAssignee("00000000-0000-0000-0000-000000000099", """
{"assigneeId": null}
""")
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("TASK_NOT_FOUND"));
updateAssignee(taskId, """
{"assigneeId": "00000000-0000-0000-0000-000000000099"}
""")
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("USER_NOT_FOUND"));
}
private ResultActions createTaskWithPoints(int points) throws Exception {
return mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\": \"Uppgift %d\", \"points\": %d}"
.formatted(points, points)));
}
private void assertInvalidTask(String body) throws Exception { private void assertInvalidTask(String body) throws Exception {
mockMvc.perform(post("/api/tasks") mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@ -104,4 +293,50 @@ class TaskApiTest {
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK")); .andExpect(jsonPath("$.code").value("INVALID_TASK"));
} }
private UUID createUser(String name) throws Exception {
String response = mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "%s"}
""".formatted(name)))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getContentAsString();
String id = com.jayway.jsonpath.JsonPath.read(response, "$.id");
return UUID.fromString(id);
}
private String createTask(
String title,
String description,
int points,
UUID assigneeId) throws Exception {
String descriptionJson = description == null ? "null" : "\"%s\"".formatted(description);
String assigneeJson = assigneeId == null ? "null" : "\"%s\"".formatted(assigneeId);
String response = mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "%s",
"description": %s,
"points": %d,
"assigneeId": %s
}
""".formatted(title, descriptionJson, points, assigneeJson)))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getContentAsString();
return com.jayway.jsonpath.JsonPath.read(response, "$.id");
}
private ResultActions updateAssignee(String taskId, String body) throws Exception {
return mockMvc.perform(put("/api/tasks/{taskId}/assignee", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content(body));
}
} }

View File

@ -0,0 +1,255 @@
package se.rubble.hemhub.task;
import java.time.Instant;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import se.rubble.hemhub.user.User;
import se.rubble.hemhub.user.UserRepository;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
class TaskStatusApiTest {
private static final Instant CREATED_AT = Instant.parse("2026-07-27T10:15:30Z");
@Autowired
private WebApplicationContext context;
@Autowired
private TaskRepository taskRepository;
@Autowired
private UserRepository userRepository;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
taskRepository.deleteAll();
mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
}
@ParameterizedTest
@CsvSource({
"WAITING, IN_PROGRESS",
"WAITING, COMPLETED",
"IN_PROGRESS, WAITING",
"IN_PROGRESS, COMPLETED",
"COMPLETED, WAITING",
"COMPLETED, IN_PROGRESS"
})
void allowsEveryDirectStatusTransition(TaskStatus initial, TaskStatus target)
throws Exception {
User assignee = createUser();
Task task = saveTask(initial, assignee);
updateStatus(task.getId(), target, assignee.getId())
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value(target.name()))
.andExpect(jsonPath("$.title").value("Dammsuga"))
.andExpect(jsonPath("$.description").value("Bottenvåningen"))
.andExpect(jsonPath("$.points").value(7))
.andExpect(jsonPath("$.createdAt").value(CREATED_AT.toString()))
.andExpect(jsonPath("$.assignee.id").value(assignee.getId().toString()));
}
@ParameterizedTest
@EnumSource(TaskStatus.class)
void acceptsCurrentStatusAsIdempotentTarget(TaskStatus statusValue) throws Exception {
User assignee = createUser();
Task task = saveTask(statusValue, assignee);
updateStatus(task.getId(), statusValue, null)
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value(statusValue.name()))
.andExpect(jsonPath("$.assignee.id").value(assignee.getId().toString()));
}
@Test
void automaticallyAssignsActiveUserWhenUnassignedTaskStarts() throws Exception {
User activeUser = createUser();
Task task = saveTask(TaskStatus.WAITING, null);
updateStatus(task.getId(), TaskStatus.IN_PROGRESS, activeUser.getId())
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("IN_PROGRESS"))
.andExpect(jsonPath("$.assignee.id").value(activeUser.getId().toString()))
.andExpect(jsonPath("$.assignee.name").value(activeUser.getName()));
}
@Test
void keepsExistingAssigneeAndDoesNotResolveActiveUser() throws Exception {
User assignee = createUser();
Task task = saveTask(TaskStatus.WAITING, assignee);
updateStatus(
task.getId(),
TaskStatus.IN_PROGRESS,
UUID.fromString("00000000-0000-0000-0000-000000000099"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.assignee.id").value(assignee.getId().toString()));
}
@Test
void requiresValidActiveUserWhenUnassignedTaskStarts() throws Exception {
Task task = saveTask(TaskStatus.COMPLETED, null);
updateStatus(task.getId(), TaskStatus.IN_PROGRESS, null)
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("TASK_REQUIRES_ASSIGNEE"));
updateStatus(
task.getId(),
TaskStatus.IN_PROGRESS,
UUID.fromString("00000000-0000-0000-0000-000000000099"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("USER_NOT_FOUND"));
}
@ParameterizedTest
@EnumSource(TaskStatus.class)
void allowsAssigningAndChangingAssigneeInEveryStatus(TaskStatus statusValue)
throws Exception {
User first = createUser();
User second = createUser();
Task task = saveTask(statusValue, first);
updateAssignee(task.getId(), second.getId())
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value(statusValue.name()))
.andExpect(jsonPath("$.assignee.id").value(second.getId().toString()));
}
@ParameterizedTest
@EnumSource(value = TaskStatus.class, names = {"WAITING", "COMPLETED"})
void allowsRemovingAssigneeOutsideInProgress(TaskStatus statusValue) throws Exception {
Task task = saveTask(statusValue, createUser());
removeAssignee(task.getId())
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value(statusValue.name()))
.andExpect(jsonPath("$.assignee").value((Object) null));
}
@Test
void rejectsRemovingAssigneeFromInProgressTask() throws Exception {
User assignee = createUser();
Task task = saveTask(TaskStatus.IN_PROGRESS, assignee);
removeAssignee(task.getId())
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("TASK_REQUIRES_ASSIGNEE"))
.andExpect(jsonPath("$.message")
.value("En pågående uppgift måste ha en ansvarig."));
}
@Test
void validatesStatusRequestAndTaskId() throws Exception {
Task task = saveTask(TaskStatus.WAITING, null);
rawStatusUpdate(task.getId().toString(), "{}")
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_STATUS"));
rawStatusUpdate(task.getId().toString(), """
{"status": null}
""")
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_STATUS"));
rawStatusUpdate(task.getId().toString(), """
{"status": "UNKNOWN"}
""")
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_STATUS"));
rawStatusUpdate(task.getId().toString(), """
{"status": "IN_PROGRESS", "activeUserId": "inte-ett-uuid"}
""")
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT"));
rawStatusUpdate(task.getId().toString(), "{")
.andExpect(status().isBadRequest());
rawStatusUpdate("00000000-0000-0000-0000-000000000099", """
{"status": "WAITING"}
""")
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("TASK_NOT_FOUND"));
}
private User createUser() throws Exception {
String name = "U" + UUID.randomUUID();
String response = mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "%s"}
""".formatted(name)))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getContentAsString();
String id = com.jayway.jsonpath.JsonPath.read(response, "$.id");
return userRepository.findById(UUID.fromString(id)).orElseThrow();
}
private Task saveTask(TaskStatus statusValue, User assignee) {
return taskRepository.save(new Task(
UUID.randomUUID(),
"Dammsuga",
"Bottenvåningen",
statusValue,
7,
assignee,
CREATED_AT));
}
private ResultActions updateStatus(
UUID taskId,
TaskStatus target,
UUID activeUserId) throws Exception {
String activeUserJson = activeUserId == null
? ""
: ", \"activeUserId\": \"%s\"".formatted(activeUserId);
return rawStatusUpdate(
taskId.toString(),
"""
{"status": "%s"%s}
""".formatted(target.name(), activeUserJson));
}
private ResultActions rawStatusUpdate(String taskId, String body) throws Exception {
return mockMvc.perform(put("/api/tasks/{taskId}/status", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content(body));
}
private ResultActions updateAssignee(UUID taskId, UUID assigneeId) throws Exception {
return mockMvc.perform(put("/api/tasks/{taskId}/assignee", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"assigneeId": "%s"}
""".formatted(assigneeId)));
}
private ResultActions removeAssignee(UUID taskId) throws Exception {
return mockMvc.perform(put("/api/tasks/{taskId}/assignee", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"assigneeId": null}
"""));
}
}

View File

@ -0,0 +1,42 @@
package se.rubble.hemhub.task;
import java.time.Instant;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertThrows;
class TaskTest {
@Test
void rejectsPointsOutsideAllowedRange() {
assertThrows(InvalidTaskException.class, () -> taskWithPoints(0));
assertThrows(InvalidTaskException.class, () -> taskWithPoints(100));
}
@Test
void rejectsInProgressTaskWithoutAssignee() {
assertThrows(
TaskRequiresAssigneeException.class,
() -> new Task(
UUID.randomUUID(),
"Dammsuga",
null,
TaskStatus.IN_PROGRESS,
3,
null,
Instant.parse("2026-07-26T12:00:00Z")));
}
private Task taskWithPoints(int points) {
return new Task(
UUID.randomUUID(),
"Dammsuga",
null,
TaskStatus.WAITING,
points,
null,
Instant.parse("2026-07-26T12:00:00Z"));
}
}

View File

@ -23,12 +23,15 @@ byggprocess.
### Frontend ### Frontend
Frontend finns i `frontend/` och använder React 19, TypeScript, Vite och pnpm. Frontend finns i `frontend/` och använder React 19, TypeScript, Vite, pnpm och
Den ansvarar för: dnd-kit-ekosystemets aktuella React-adapter. Den ansvarar för:
- hämtning och presentation av användare och uppgifter; - hämtning och presentation av användare och uppgifter;
- lokalt val av aktiv användare; - lokalt val av aktiv användare;
- formulär för att skapa användare och uppgifter; - formulär för att skapa användare och uppgifter;
- val och visning av ansvarig användare på uppgifter;
- serverbekräftade statusändringar genom knappar på uppgiftskorten;
- optimistiska statusflyttar genom drag-and-drop mellan brädans kolumner;
- klientnära validering och begripliga felmeddelanden; - klientnära validering och begripliga felmeddelanden;
- uppgiftsbrädan med kolumnerna Väntande, Pågående och Klart. - uppgiftsbrädan med kolumnerna Väntande, Pågående och Klart.
@ -60,12 +63,14 @@ Aktuella endpoints:
- `POST /api/users` - `POST /api/users`
- `GET /api/tasks` - `GET /api/tasks`
- `POST /api/tasks` - `POST /api/tasks`
- `PUT /api/tasks/{taskId}/assignee`
- `PUT /api/tasks/{taskId}/status`
### Databas och migreringar ### Databas och migreringar
Lokal körning använder en filbaserad H2-databas under `backend/data`. Katalogen Lokal körning använder en H2-databas i minnet. Databasen finns under
ignoreras av Git. Automatiska backendtester använder en separat H2-databas i backendprocessens livstid och lokal utvecklingsdata återställs när backend
minnet. startas om. Automatiska backendtester använder en separat H2-databas i minnet.
Båda anslutningarna använder H2:s `MODE=PostgreSQL`, Båda anslutningarna använder H2:s `MODE=PostgreSQL`,
`DATABASE_TO_LOWER=TRUE` och `DEFAULT_NULL_ORDERING=HIGH`. Det är en verifierbar `DATABASE_TO_LOWER=TRUE` och `DEFAULT_NULL_ORDERING=HIGH`. Det är en verifierbar
@ -76,6 +81,8 @@ Flyway kör migreringarna:
- `V1__create_users.sql` - `V1__create_users.sql`
- `V2__create_tasks.sql` - `V2__create_tasks.sql`
- `V3__add_task_points.sql`
- `V4__add_task_assignee.sql`
Hibernate är konfigurerat med `ddl-auto=validate`; Flyway skapar schemat och Hibernate är konfigurerat med `ddl-auto=validate`; Flyway skapar schemat och
Hibernate validerar entiteterna mot det. Hibernate validerar entiteterna mot det.
@ -102,11 +109,25 @@ En uppgift lagras i tabellen `task` med:
- `title`: obligatorisk titel, högst 100 tecken; - `title`: obligatorisk titel, högst 100 tecken;
- `description`: valfri beskrivning, högst 500 tecken; - `description`: valfri beskrivning, högst 500 tecken;
- `status`: `WAITING`, `IN_PROGRESS` eller `COMPLETED`; - `status`: `WAITING`, `IN_PROGRESS` eller `COMPLETED`;
- `points`: obligatoriskt heltal mellan 1 och 99;
- `assignee_id`: nullable främmande nyckel till `app_user`;
- `created_at`: en `Instant`, lagrad som `TIMESTAMP WITH TIME ZONE`. - `created_at`: en `Instant`, lagrad som `TIMESTAMP WITH TIME ZONE`.
Status lagras som enumens textvärde genom `EnumType.STRING`. Nya uppgifter får Status lagras som enumens textvärde genom `EnumType.STRING`. Nya uppgifter får
alltid status `WAITING`. Det finns ingen relation mellan uppgifter och alltid status `WAITING`. Poängintervallet skyddas i backend och med en
användare; alla aktiva användare ser samma uppgiftslista. databasconstraint. En uppgift kan vara otilldelad eller referera till exakt en
ansvarig användare. Relationen hämtas tillsammans med uppgifterna när de listas,
så API-responsen kan innehålla ansvarigs `id` och `name` utan separata
frontend-anrop. Alla aktiva användare ser samma uppgiftslista.
Ansvarig är valfri vid skapande. Tilldelnings-API:t kan tilldela eller byta
ansvarig i samtliga statusar. Ansvarig kan tas bort i `WAITING` och `COMPLETED`,
men inte i `IN_PROGRESS`. Tilldelning ändrar aldrig uppgiftens status.
Alla direkta statusövergångar är tillåtna och samma målstatus är idempotent.
`IN_PROGRESS` kräver en ansvarig. När en otilldelad uppgift påbörjas skickar
frontend aktiv användares id, och backend tilldelar användaren och ändrar status
i samma transaktion. En befintlig ansvarig byts aldrig av statusoperationen.
### Aktiv användare ### Aktiv användare
@ -121,12 +142,19 @@ lokalt per browser och utgör inte autentisering eller behörighetskontroll.
### Felhantering ### Felhantering
Backend använder ett litet gemensamt JSON-format med `code` och `message`. Backend använder ett litet gemensamt JSON-format med `code` och `message`.
`ApiExceptionHandler` översätter kända valideringsfel till `400 Bad Request` `ApiExceptionHandler` översätter kända valideringsfel till `400 Bad Request`,
och dubbletter av användarnamn till `409 Conflict`. saknade uppgifter eller användare till `404 Not Found` och dubbletter eller
otillåtna tilldelningsändringar till `409 Conflict`.
Frontend skiljer mellan fel vid hämtning och skapande. Hämtfel kan Frontend skiljer mellan fel vid hämtning och skapande. Hämtfel kan
återförsökas. Formulärfel visas nära formuläret och inmatningen behålls vid återförsökas. Formulärfel visas nära formuläret och inmatningen behålls vid
misslyckade API-anrop. misslyckade API-anrop. Status- och tilldelningsfel visas lokalt på berört kort;
statusknappar och tilldelning uppdateras först med backendens bekräftade
respons. Drag-and-drop flyttar kortet optimistiskt men återställer hela den
tidigare uppgiften vid fel. Vid framgång ersätts alltid det lokala värdet med
backendens fullständiga respons. Status- och tilldelningsanrop delar låsning per
task-id, så det berörda kortet blockeras utan att resten av brädan låses.
Drag-and-drop återanvänder backendens befintliga status-API oförändrat.
### Teststrategi ### Teststrategi
@ -138,7 +166,9 @@ Backend har JUnit 5-tester:
Frontend använder Vitest, jsdom och React Testing Library. `fetch` och Frontend använder Vitest, jsdom och React Testing Library. `fetch` och
`localStorage` ersätts i testerna, så frontendtesterna kräver inte en körande `localStorage` ersätts i testerna, så frontendtesterna kräver inte en körande
backend. Produktionsbygget kör TypeScript-kompilering följt av Vite. backend. Drag-and-drop-adaptern översätter bibliotekshändelser till task-id och
status, så stateflöden kan testas utan att simulera fysisk layout i jsdom.
Produktionsbygget kör TypeScript-kompilering följt av Vite.
### Produktionsdeployment ### Produktionsdeployment

View File

@ -2,7 +2,7 @@
## Status ## Status
Planerad. Färdig och mergad till `main`.
## Bakgrund ## Bakgrund
@ -315,17 +315,16 @@ Detta innebär att Feature 3 inte behöver migrera verkliga befintliga
utvecklingsposter. En ny databas skapas direkt med det obligatoriska utvecklingsposter. En ny databas skapas direkt med det obligatoriska
poängfältet. poängfältet.
Codex ska kontrollera repositoryts faktiska konfiguration. Om H2 för närvarande Före Feature 3 var lokal H2 filbaserad. Feature 3 ändrar utvecklingsanslutningen
är filbaserad ska den ändras till in-memory och relevant till in-memory och uppdaterar utvecklingsdokumentationen i samma ändring.
utvecklingsdokumentation ska uppdateras.
## Schemahantering och framtida migrering ## Schemahantering och framtida migrering
Att lokal utvecklingsdata inte bevaras innebär inte att framtida Att lokal utvecklingsdata inte bevaras innebär inte att framtida
produktionsdata kan återställas vid varje release. produktionsdata kan återställas vid varje release.
När HemHub börjar använda en beständig Postgres-databas med data som ska bevaras När HemHub börjar använda en beständig PostgreSQL-databas med data som ska
måste schemaändringar hanteras med kontrollerade migreringar. bevaras måste schemaändringar hanteras med kontrollerade migreringar.
Feature 3 behöver inte införa eller färdigställa hela den framtida Feature 3 behöver inte införa eller färdigställa hela den framtida
produktionsstrategin om den ännu inte finns i repositoryt. produktionsstrategin om den ännu inte finns i repositoryt.
@ -344,7 +343,7 @@ Repositoryts faktiska arkitektur och dokumentation har företräde.
## Backendtester ## Backendtester
Feature 3 ska minst verifiera att: Backendtesterna verifierar att:
- en uppgift kan skapas med ett giltigt `points`; - en uppgift kan skapas med ett giltigt `points`;
- det skapade API-svaret innehåller samma `points`; - det skapade API-svaret innehåller samma `points`;
@ -357,12 +356,12 @@ Feature 3 ska minst verifiera att:
- negativa värden ger `400 Bad Request`; - negativa värden ger `400 Bad Request`;
- `points: 100` ger `400 Bad Request`. - `points: 100` ger `400 Bad Request`.
Testerna ska följa befintlig teststil och utöka nuvarande tester där det är Testerna följer den befintliga teststilen och utökar de tidigare
lämpligt. uppgifts-API-testerna.
## Frontendtester ## Frontendtester
Feature 3 ska minst verifiera att: Frontendtesterna verifierar att:
- skapandedialogen öppnas med poängvärdet `1`; - skapandedialogen öppnas med poängvärdet `1`;
- ett giltigt poängvärde skickas i create-anropet; - ett giltigt poängvärde skickas i create-anropet;
@ -374,11 +373,11 @@ Feature 3 ska minst verifiera att:
- ett uppgiftskort visar uppgiftens dynamiska poängbadge; - ett uppgiftskort visar uppgiftens dynamiska poängbadge;
- badgen visar värdet från uppgiftsdata, exempelvis `7 p`. - badgen visar värdet från uppgiftsdata, exempelvis `7 p`.
Testerna ska inte vara beroende av en viss pixelplacering eller detaljerad CSS. Testerna är inte beroende av en viss pixelplacering eller detaljerad CSS.
## Manuell verifiering ## Manuell verifiering
Följande ska verifieras manuellt: Följande verifierades manuellt:
1. Starta frontend och backend enligt projektets utvecklingsinstruktioner. 1. Starta frontend och backend enligt projektets utvecklingsinstruktioner.
2. Skapa en uppgift utan att ändra poängfältet. 2. Skapa en uppgift utan att ändra poängfältet.
@ -437,3 +436,8 @@ Dokumentation, implementation och tester ska uppdateras tillsammans.
Codex ska inte committa, pusha, skapa pull request eller merga utan uttrycklig Codex ska inte committa, pusha, skapa pull request eller merga utan uttrycklig
instruktion. instruktion.
## Relaterade commits
- `059d4da9214969ed3e28592178160da6de614b4d` `feat: add task points`
- `2e62261f49bb3142e28882483e41e0250ab11c5f` merge till `main`

View File

@ -0,0 +1,123 @@
# Feature 4 Tilldelning av uppgifter
## Status
Färdig och mergad till `main`.
## Bakgrund
HemHub har centralt lagrade användare och gemensamma uppgifter. För att senare
kunna införa regler för pågående arbete behöver en uppgift kunna ha en ansvarig
användare, utan att tilldelning samtidigt ändrar uppgiftens status.
## Mål
- välja en valfri ansvarig när en uppgift skapas;
- visa ansvarig på uppgiftskortet;
- tilldela, byta eller ta bort ansvarig på en väntande uppgift;
- lagra tilldelningen centralt så att alla användare ser samma värde.
## Omfattning
En uppgift kan vara otilldelad eller tilldelad exakt en befintlig användare.
`Ingen` är standard vid skapande och den aktiva browseranvändaren förväljs
inte. Frontend återanvänder användarlistan som redan hämtas vid appstart.
På ett otilldelat väntande kort öppnar `Ta uppgift` ett användarval. Ett
tilldelat väntande kort visar namnet och öppnar samma val. Ändringen skickas
direkt till backend och kortet uppdateras först med den bekräftade responsen.
Vid fel behålls den tidigare tilldelningen och ett lokalt felmeddelande visas.
För `IN_PROGRESS` och `COMPLETED` visas ansvarig eller `Otilldelad` utan
redigerbar kontroll.
## Produktregler
- En uppgift har högst en ansvarig.
- Ansvarig är valfri och måste motsvara en befintlig användare.
- Endast uppgifter med status `WAITING` får få ändrad ansvarig.
- Tilldelning ändrar aldrig status, titel, beskrivning eller poäng.
- Vem som helst kan välja valfri ansvarig; aktiv användare är inte
autentisering eller behörighetskontroll.
## API-förändringar
`POST /api/tasks` accepterar det valfria fältet `assigneeId`. Saknat fält eller
`null` skapar en otilldelad uppgift. Ett UUID som inte motsvarar en användare
ger `404 Not Found`.
`PUT /api/tasks/{taskId}/assignee` ändrar endast ansvarig:
```json
{"assigneeId": "d56b54dd-31b0-4d71-8a10-82464be59a61"}
```
`{"assigneeId": null}` tar bort tilldelningen. Fältet måste finnas i requesten.
Responsen är den uppdaterade uppgiften. Task-responser innehåller:
```json
{"assignee": {"id": "d56b54dd-31b0-4d71-8a10-82464be59a61", "name": "Anna"}}
```
Otilldelade uppgifter har `"assignee": null`. Ogiltigt UUID eller saknat fält
ger `400`, okänd uppgift eller användare ger `404` och ändring av en uppgift
som inte väntar ger `409`. Felen använder det befintliga formatet med `code`
och `message`.
## Databasförändringar
`V4__add_task_assignee.sql` lägger till `task.assignee_id UUID NULL` med en
främmande nyckel till `app_user.id`. Befintliga uppgifter blir otilldelade.
Migreringen använder varken `ON DELETE CASCADE` eller `ON DELETE SET NULL`.
JPA-modellen använder en lazy `ManyToOne`. Repositoryts listning och
id-hämtning använder en entity graph för att hämta ansvarig tillsammans med
uppgiften och undvika N+1-frågor när responsen byggs.
## Frontendförändringar
Skapandedialogen innehåller ett tilldelningsval med `Ingen` och samtliga
användare. Valet bevaras tillsammans med övriga formulärvärden vid fel.
Väntande kort har en separat tilldelningskontroll. Kontrollen är inaktiverad
medan just det kortets request pågår; övriga delar av brädan förblir
interaktiva. Serverns task-respons ersätter motsvarande uppgift i den befintliga
listan utan att ändra ordningen.
## Tester och verifiering
Backendens integrationstester täcker skapande med och utan ansvarig,
responsformat, okända id:n, tilldelning, byte, av-tilldelning, statuskonflikt
och att övriga uppgiftsfält inte ändras.
Frontendtesterna täcker standardval och användarlista i skapandedialogen,
create-requestens `assigneeId`, kortens redigerbara och statiska lägen,
tilldelningsrequest, vänteläge, serverbekräftad uppdatering, av-tilldelning och
fel utan optimistisk ändring.
Manuell verifiering genomfördes för skapande med och utan ansvarig, tilldelning,
byte, av-tilldelning, bevarad status, omladdning, statiska kontroller för andra
statusar, felrespons och projektets normala desktop- och mobilbredder.
## Avgränsning mot Feature 5
Feature 4 inför inget API eller UI för statusändring och inte regeln att
`IN_PROGRESS` måste ha en ansvarig. Tilldelning leder inte automatiskt till
`IN_PROGRESS`, och av-tilldelning leder inte automatiskt till `WAITING`.
## Ingår inte
Flera ansvariga, statusändring, drag-and-drop, generell redigering, radering,
deadlines, återkommande uppgifter, poänghistorik, användaradministration,
autentisering, behörighetskontroll och automatisk tilldelning ingår inte.
## Kända begränsningar
Användare kan ännu inte raderas, så relationens framtida beteende vid
användarradering är inte beslutat. Frontend har ingen optimistisk uppdatering;
det tidigare värdet ligger kvar tills backend svarar.
## Relaterade commits
- `aaebe888f3bae43b3413e9fa3893fec90afac8b4` `feat: add task assignment`
- `d78611f5f77374228f1f69b66734931a988f8355` merge till `main`

View File

@ -0,0 +1,124 @@
# Feature 5 Statusändring och statusregler
## Status
Färdig och mergad till `main`.
## Bakgrund
Efter Feature 4 kan uppgifter vara otilldelade eller ha en ansvarig, men inget
API eller gränssnitt kan ändra status. Feature 5 inför ett enkelt knappflöde
före drag-and-drop och säkerställer statusreglerna i backend.
## Mål
- ändra status genom ett särskilt backend-API;
- tillåta direkta övergångar mellan `WAITING`, `IN_PROGRESS` och `COMPLETED`;
- kräva ansvarig för `IN_PROGRESS`;
- automatiskt tilldela aktiv browseranvändare när en otilldelad uppgift
påbörjas;
- tillåta statusberoende ändringar av ansvarig;
- använda serverbekräftade uppdateringar och vänteläge per kort.
## Status- och tilldelningsregler
Alla statusar får ändras direkt till varandra. Ett anrop med aktuell status som
mål är giltigt och idempotent. En uppgift behöver inte passera
`IN_PROGRESS` för att bli `COMPLETED`.
`WAITING` och `COMPLETED` får vara tilldelade eller otilldelade.
`IN_PROGRESS` måste alltid ha en ansvarig. Det befintliga tilldelnings-API:t
kan tilldela eller byta ansvarig i samtliga statusar och ta bort ansvarig i
`WAITING` och `COMPLETED`. Ett försök att ta bort ansvarig i `IN_PROGRESS`
avvisas med `409 TASK_REQUIRES_ASSIGNEE`. Tilldelning ändrar aldrig status.
När en otilldelad uppgift ändras till `IN_PROGRESS` skickar frontend aktiv
användares id. Backend verifierar användaren, tilldelar den och ändrar status i
samma transaktion. Om uppgiften redan har en ansvarig behålls den, och skickat
`activeUserId` används inte för att byta ansvarig.
## API-förändringar
Status ändras med:
```http
PUT /api/tasks/{taskId}/status
```
```json
{
"status": "IN_PROGRESS",
"activeUserId": "d56b54dd-31b0-4d71-8a10-82464be59a61"
}
```
`status` är obligatoriskt. `activeUserId` krävs endast när en otilldelad
uppgift ska bli `IN_PROGRESS`. Responsen använder samma fullständiga
task-format som övriga task-operationer.
Kända fel använder befintligt format med `code` och `message`:
- okänd uppgift: `404 TASK_NOT_FOUND`;
- saknad, null eller okänd status: `400 INVALID_TASK_STATUS`;
- okänd användare: `404 USER_NOT_FOUND`;
- otilldelad `IN_PROGRESS`: `409 TASK_REQUIRES_ASSIGNEE`;
- ogiltigt UUID-format: `400` med befintlig requestfelkod.
`USER_NOT_FOUND` och `INVALID_TASK_ASSIGNMENT` återanvänds från Feature 4 i
stället för att införa parallella felkoder för samma användar-id.
## Databasförändringar
Inga. Befintliga kolumner för status och ansvarig är tillräckliga.
## Frontendförändringar
Varje kort visar två statusknappar:
- `WAITING`: `Påbörja`, `Markera klar`;
- `IN_PROGRESS`: `Till Väntande`, `Markera klar`;
- `COMPLETED`: `Till Väntande`, `Påbörja igen`.
Tilldelningskontrollen är redigerbar i alla statusar. Alternativet `Ingen`
visas inte för `IN_PROGRESS`.
Status och tilldelning delar ett vänteläge per uppgift. Under ett anrop ligger
kortet kvar i sin kolumn och båda kontrollerna på kortet är inaktiverade.
Övriga kort är fortsatt interaktiva. Vid framgång ersätts uppgiften med
serverresponsen; vid fel behålls tidigare data och felet visas lokalt.
## Tester och verifiering
Backendtesterna omfattar samtliga direkta övergångar, idempotens, automatisk
tilldelning, bevarad ansvarig, statusvalidering, okända id:n, statusberoende
tilldelning och oförändrade uppgiftsfält.
Frontendtesterna omfattar knapparnas statusmappning, requestformat,
serverbekräftad flytt, automatisk tilldelning i responsen, lokalt vänteläge,
dubbelsubmitsskydd, fel utan optimistisk ändring och statusberoende
tilldelningsalternativ.
Manuell verifiering genomfördes genom ett sammanhängande flöde genom alla tre
statusar, automatisk tilldelning, byte och borttagning av ansvarig, omladdning
och centrala API-fel. Browserflödet verifierades i desktop- och mobilbredd utan
upptäckta problem.
## Avgränsningar
Ingen drag-and-drop, radering, generell redigering, sortering, deadline,
återkommande uppgift, status- eller poänghistorik, slutföranderegistrering,
statistik, användaradministration, autentisering eller behörighetskontroll
införs.
## Kända begränsningar
Aktiv användare är ett lokalt browserval och inte autentisering. Backend kan
verifiera att id:t finns, men inte vem som faktiskt använder browsern.
Statusknapparna är ett första gränssnitt före Feature 6. Det finns ingen
versionskontroll för konkurrerande uppdateringar utöver transaktioner och
aktuell serverlogik.
## Relaterade commits
- `65a6488c0b268f49b1361591f025bdea8d67f754` `feat: add task status transitions`

View File

@ -0,0 +1,545 @@
# Feature 6 Drag-and-drop
## Status
Färdig och verifierad på feature-branchen, ännu inte mergad till `main`.
## Bakgrund
Feature 5 införde backendstyrda statusövergångar mellan `WAITING`,
`IN_PROGRESS` och `COMPLETED`, ett särskilt status-API samt ett första
knappbaserat gränssnitt för statusändring.
Feature 6 ska komplettera detta med drag-and-drop mellan brädans
statuskolumner. Featuren ska återanvända den befintliga statusmodellen,
status-API:t, tilldelningsreglerna och frontendens hantering av vänteläge och
lokala fel. Den ska inte skapa en parallell statusmekanism.
## Mål
Feature 6 ska:
- låta användaren dra uppgiftskort mellan statuskolumner;
- använda status-API:t från Feature 5;
- ge omedelbar visuell återkoppling genom optimistisk flytt;
- återställa kortet om backend avvisar statusändringen;
- hantera automatisk tilldelning när en otilldelad uppgift dras till Pågående;
- behålla befintliga statusknappar som ett tillfälligt alternativ;
- fungera med mus och på en rimlig nivå med touch;
- hålla dragmekanik, statuslogik och kortlayout tillräckligt separerade för
framtida ändringar.
## Omfattning
Feature 6 är primärt en frontendfeature.
Backendens befintliga endpoint används:
```http
PUT /api/tasks/{taskId}/status
```
Requesten innehåller målstatus och kan innehålla aktiv användares id:
```json
{
"status": "IN_PROGRESS",
"activeUserId": "d56b54dd-31b0-4d71-8a10-82464be59a61"
}
```
Backend ska endast ändras om granskning av den faktiska implementationen visar
att en mindre korrigering behövs för att det befintliga kontraktet ska kunna
återanvändas korrekt.
## Implementerad lösning
Frontend använder `@dnd-kit/react` 0.5.0 som aktuell React-adapter och
`@dnd-kit/dom` 0.5.0 för en konfigurerad pointer-sensor. Legacy-paketen
`@dnd-kit/core` och `@dnd-kit/sortable` används inte. Sortable-stöd behövs inte
eftersom kort inte ordnas inom kolumner.
`TaskDragAndDrop.tsx` avgränsar bibliotekskopplingen. Adaptern:
- registrerar uppgiftskort som draggable och statuskolumner som droppable;
- översätter ett lyckat drop-event till task-id och målstatus;
- ignorerar avbrutna dragningar och ogiltiga mål;
- använder sex pixlars aktiveringsavstånd för mus och penna;
- använder 250 millisekunders fördröjning med åtta pixlars tolerans för touch;
- behåller dnd-kits standardplugins och tangentbordssensor;
- förlitar sig på sensorns standardskydd mot dragstart från interaktiva
element.
Ingen särskild `touch-action`, collision detector eller drag-overlay har lagts
till. Den befintliga responsiva layouten är oförändrad.
`TaskBoard` har fortsatt en gemensam requestväg och låsning per task-id för
statusändringar. Ett presentationsval avgör beteendet:
- statusknappar använder `server-confirmed` och flyttar inte kortet före svar;
- drag-and-drop använder `optimistic`, sparar hela tidigare uppgiften och
flyttar kortet direkt;
- lyckade anrop ersätter uppgiften med hela serverresponsen;
- misslyckade optimistiska anrop återställer hela rollback-värdet och visar
felet lokalt.
När en otilldelad uppgift dras till `IN_PROGRESS` visar det optimistiska värdet
den aktiva användarens id och namn. En befintlig ansvarig behålls. Backendens
befintliga status-API och Feature 5-regler återanvänds utan backendändringar.
Frontendens 44 tester, TypeScript-kompilering, Vites produktionsbygge och
`git diff --check` passerar på feature-branchen. Manuell browserverifiering av
de centrala drag-, server- och rollbackflödena är genomförd.
## Avgränsningar
Feature 6 ska inte införa:
- en ny statusmodell;
- ett nytt eller parallellt status-API;
- manuell sortering inom en kolumn;
- persistent kortordning;
- generell redigering av uppgifter;
- radering;
- deadlines;
- återkommande uppgifter;
- status- eller poänghistorik;
- undo-funktion;
- flera ansvariga;
- autentisering eller behörigheter;
- realtidsuppdatering mellan browsers;
- en ny global state-lösning;
- horisontell scrollning som ett särskilt mobilkoncept;
- ett specialbyggt tangentbordsflöde för drag-and-drop.
Att flytta ett kort inom samma kolumn ska inte ändra någon ordning och ska inte
ge ett backend-anrop.
## Befintliga statusregler
Feature 6 ska behandla följande regler som redan beslutade:
- statusarna är `WAITING`, `IN_PROGRESS` och `COMPLETED`;
- alla direkta statusövergångar är tillåtna;
- samma målstatus är giltig och idempotent;
- `IN_PROGRESS` kräver ansvarig;
- `WAITING` och `COMPLETED` får vara otilldelade;
- när en otilldelad uppgift sätts till `IN_PROGRESS` skickar frontend aktiv
användares id;
- backend tilldelar då användaren och ändrar status atomärt;
- om uppgiften redan har en ansvarig behålls denna;
- statusoperationen byter aldrig en befintlig ansvarig;
- backend returnerar hela den uppdaterade uppgiften;
- serverns fullständiga task-respons är slutlig sanning.
## Uppdateringsstrategi
Drag-and-drop använder en kontrollerad optimistisk uppdatering.
När ett kort släpps i en annan statuskolumn ska frontend:
1. spara hela den nuvarande task-versionen som rollback-värde;
2. skapa ett optimistiskt lokalt task-läge;
3. visa kortet omedelbart i målkolumnen;
4. markera kortet som upptaget;
5. skicka statusanropet;
6. vid framgång ersätta den optimistiska uppgiften med serverns fullständiga
respons;
7. vid fel återställa hela den tidigare task-versionen;
8. visa felet lokalt på det återställda kortet.
Rollback ska återställa hela uppgiften, inte enbart statusfältet. Detta är
viktigt eftersom en optimistisk flytt till `IN_PROGRESS` även kan innehålla en
tillfällig antagen ansvarig.
Ingen generell infrastruktur för optimistiska uppdateringar ska införas.
Lösningen ska hållas lokal till uppgiftslistan och drag-and-drop-flödet.
Feature 5:s statusknappar ska fortsatt vara serverbekräftade: kortet ligger kvar
i sin aktuella kolumn tills backend svarar.
## Otilldelad uppgift till Pågående
När en otilldelad uppgift dras till `IN_PROGRESS` ska frontend:
- skicka målstatus `IN_PROGRESS`;
- skicka aktiv användares id;
- optimistiskt visa aktiv användare som ansvarig;
- låta backend tilldela användaren och ändra status i samma transaktion.
Om uppgiften redan har en ansvarig ska frontend inte optimistiskt ersätta denna
med den aktiva användaren. Serverresponsen ersätter alltid det optimistiska
antagandet.
Drag-and-drop ska inte visa ett nytt ansvarigval.
## Drag-and-drop-bibliotek
Feature 6 ska använda dnd-kit-ekosystemets aktuella stabila React-lösning.
Featuredokumentet låser inte exakta paket eller API:n. Codex ska vid
implementation verifiera den aktuella officiella dokumentationen, vilka paket
som är aktuella respektive legacy, kompatibilitet med repositoryts
React-version samt påverkan på Vitest/jsdom och React Testing Library.
Den valda lösningen ska stödja:
- draggable-kort;
- droppable-statuskolumner;
- pointer- och touchinteraktion;
- aktiveringsvillkor;
- avbruten dragning;
- identifiering av målkolumn.
Biblioteket ska inte användas för:
- sortering inom kolumner;
- persistent ordning;
- egen domänmodell;
- generell state-hantering;
- parallell statuslogik.
Dragbibliotekets händelser ska översättas till en gemensam,
bibliotekoberoende ingång till statusflödet.
## Statusknapparnas roll
Feature 5:s statusknappar behålls tills vidare som ett fullt fungerande
alternativ. De betraktas inte som en permanent del av målbilden. Den
ursprungliga visuella designen innehåller inte statusknappar, och de ska därför
enkelt kunna tas bort efter utvärdering.
Implementation ska följa dessa principer:
- drag-and-drop får inte byggas ovanpå knappkomponenterna;
- draglogik får inte placeras i knapparna;
- statuslogik får inte dupliceras mellan knappar och drag-and-drop;
- båda ska dela underliggande requestlogik, låsning per task-id, felhantering
och ersättning med serverrespons;
- drag-and-drop använder optimistisk visuell flytt, medan statusknapparna
förblir serverbekräftade och låter kortet ligga kvar tills backend svarar;
- den visuella presentationsstrategin får därför skilja sig mellan
interaktionerna;
- vänteläge, felhantering och serverrespons ska höra till uppgiften och det
delade statusflödet, inte dupliceras per kontroll;
- kortlayouten får inte strukturellt förutsätta att knapparna alltid finns.
Att ta bort statusknapparna senare ska inte kräva ändringar i status-API,
dragmekanik, rollback eller task-state. Statusarkitekturen får inte vara kopplad
till att knapparna finns kvar.
## Vänteläge
När statusanropet pågår ska det optimistiskt flyttade kortet tonas ned lätt.
Ingen text som `Flyttar…` och ingen spinner krävs i första versionen.
Under vänteläget ska just detta kort inte kunna:
- dras igen;
- initiera en ny statusändring;
- ändra ansvarig.
Övriga kort ska förbli interaktiva.
Flera olika kort får ha statusanrop pågående samtidigt. Låsning, nedtoning,
rollback och fel hanteras per task-id.
## Fel och återställning
Vid ett misslyckat statusanrop ska:
- hela den tidigare task-versionen återställas;
- kortet återgå till ursprungskolumnen;
- tidigare ansvarig återställas;
- nedtoningen tas bort;
- felet visas lokalt på kortet;
- målkolumnen inte behålla någon tillfällig markering.
Tidigare klientdata ska inte delvis blandas med det misslyckade optimistiska
tillståndet. Ett fel på ett kort ska inte blockera resten av brädan.
## Serverrespons
Vid lyckat statusanrop ska frontend alltid ersätta den optimistiska uppgiften
med hela serverresponsen.
Det gäller även om serverresponsen skiljer sig från frontendens antagande vad
gäller exempelvis:
- status;
- ansvarig;
- andra returnerade task-fält.
Servern är slutlig sanning.
## Dragyta
Kortets icke-interaktiva yta ska fungera som dragyta. Knappar, select och andra
interaktiva element ska inte initiera dragning.
Implementation får avgöra sensoruppsättning, aktiveringströskel samt avstånd,
fördröjning och tolerans. Vanliga klick och små fingerrörelser får inte
oavsiktligt starta dragning, och kortets interaktiva kontroller ska fungera
normalt.
Dragmekaniken ska implementeras så att ett separat draghandtag senare kan
införas utan att statusoperation, API-anrop, rollback eller task-state behöver
ändras. Det bör räcka att flytta bibliotekets draglisteners och tillhörande
attribut från kortets rot till handtaget.
## Målkolumner
Kolumnerna behöver ingen stark eller permanent drop-markering. Den kolumn som
kortet befinner sig över kan vid behov få en mycket diskret hover-effekt,
exempelvis:
- en svag bakgrundsförändring;
- en tunn kant;
- annan lågmäld visuell återkoppling.
Feature 6 ska inte införa stora färgade drop-zoner eller en generell redesign
av brädan. Om manuell verifiering visar att ingen markering behövs kan även den
diskreta hover-effekten utelämnas.
## Drop i samma kolumn
Om ett kort släpps i kolumnen som motsvarar dess nuvarande status ska
operationen vara no-op.
Det innebär:
- inget API-anrop;
- ingen statusändring;
- ingen ändring av ordning;
- inget vänteläge;
- inget felmeddelande.
## Avbruten dragning
Om en dragning avbryts eller avslutas utanför en giltig målkolumn ska
operationen vara no-op. Kortet ska återgå till sin normala position utan
API-anrop eller felindikering.
## Desktop och touch
Desktop är det primära användningsfallet för Feature 6.
Touch ska fungera på en rimlig grundnivå, men featuren ska inte införa en
särskild mobil Kanban-design. Brädans befintliga responsiva layout ska
behållas. Feature 6 ska inte införa horisontell scrollning. På smala skärmar
får kolumnerna fortsätta använda repositoryts nuvarande responsiva layout,
även om de staplas vertikalt.
Implementation får avgöra sensoruppsättning, aktiveringsvillkor, eventuell
`touch-action`, collision detection och drag-overlay. Valen måste bevara normal
vertikal scrollning på mobil och får inte göra interaktiva kortkontroller
svåranvända.
Draglogiken ska:
- identifiera mål genom status, inte genom en fast skärmposition;
- inte förutsätta att kolumnerna ligger horisontellt;
- hållas separerad från layout-CSS;
- kunna fortsätta fungera om kolumnlayouten senare ändras.
Om manuell verifiering visar att dragning mellan staplade kolumner fungerar
dåligt kan mobilbeteendet ändras senare utan att status- eller rollbacklogiken
görs om. Statusknapparna finns kvar som alternativ, särskilt där dragning är
opraktisk.
## Tangentbord och tillgänglighet
Tangentbordsstyrd drag-and-drop ingår inte som grundkrav i Feature 6.
Statusknapparna ska fortsatt ge en fungerande tangentbordsväg för
statusändring. Drag-and-drop får därför inte vara den enda möjliga vägen.
Feature 6 ska ändå uppfylla grundläggande tillgänglighetskrav:
- interaktiva kontroller ska fortsatt gå att nå med tangentbord;
- ett upptaget kort ska inte kunna aktiveras igen;
- fokus ska inte tappas oförklarligt efter lyckad operation eller rollback;
- begripliga etiketter ska bevaras;
- dragbibliotekets standard-ARIA får användas;
- ingen omfattande speciallösning för tangentbordsdragning ska byggas.
Förbättrat tangentbordsstöd för själva dragningen kan införas i en senare
uppdatering.
## Gemensamt statusflöde
Frontend ska ha ett gemensamt, kontrolloberoende statusflöde för den
underliggande statusändringen.
Det delade flödet ska ansvara för:
- kontroll av pågående operation för task-id;
- requestformat;
- aktiv användares id vid behov;
- per-kort-vänteläge;
- ersättning med serverrespons;
- lokalt statusfel.
Drag-and-drop-flödet ska därutöver beräkna den optimistiska task-versionen,
spara rollback-värdet och återställa hela den tidigare uppgiften vid fel.
Statusknapparna ska inte göra en optimistisk flytt.
Drag-and-drop och statusknappar är separata sätt att ange målstatus till det
delade flödet, men får använda olika visuell presentationsstrategi.
Tilldelningskontrollen ska återanvända samma per-kort-låsning så att status och
tilldelning inte kan ändras samtidigt på samma uppgift.
## Frontendtester
Frontendtesterna verifierar beteende och state utan att förutsätta fysisk
layout eller exakta pointer-koordinater i jsdom. Dragadaptern mockas i
brädtesterna, medan den bibliotekoberoende mappningen från draghändelse till
task-id och målstatus testas separat.
Testerna täcker bland annat:
- korrekt statusanrop och optimistisk flytt till en annan kolumn;
- låsning och nedtoning per task-id medan anropet pågår;
- att andra kort kan ha samtidiga operationer;
- automatisk optimistisk tilldelning till aktiv användare;
- att en befintlig ansvarig behålls;
- att hela serverresponsen ersätter det optimistiska värdet;
- fullständig rollback och lokalt fel vid misslyckande;
- no-op för samma status, avbruten dragning och ogiltigt mål;
- att statusknapparnas befintliga serverbekräftade beteende är bevarat.
Totalt passerar 44 frontendtester. TypeScript-kompileringen och Vites
produktionsbygge passerar också.
## Backendtester
Backend ändrades inte. Feature 5:s befintliga tester fortsätter därför att
utgöra verifiering av:
- direkta statusövergångar;
- idempotens;
- automatisk tilldelning;
- bevarad befintlig ansvarig;
- statusvalidering;
- statusberoende tilldelningsregler;
- oförändrade övriga task-fält.
## Manuell verifiering
Manuell browserverifiering är genomförd. Följande verifierades:
- drag-and-drop mellan statuskolumner fungerar;
- kortet flyttas optimistiskt och tonas ned under statusanropet;
- en otilldelad uppgift som flyttas till Pågående får aktiv användare som
ansvarig;
- en befintlig ansvarig behålls;
- serverns svar ersätter det optimistiska värdet;
- statusknapparna fungerar fortsatt;
- ett blockerat statusanrop visar
`Det gick inte att ändra status. Försök igen.`;
- kortet återställs till ursprungskolumnen vid fel;
- tidigare ansvarig återställs, nedtoningen försvinner och kortet blir
interaktivt igen;
- övriga kort förblir interaktiva under operationen.
## Dokumentation
Feature 6 ska dokumenteras i:
```text
docs/features/006-task-drag-and-drop.md
```
Vid implementation ska även följande uppdateras när det är relevant:
```text
README.md
docs/architecture.md
docs/roadmap.md
```
Roadmapen behåller statusen `Pågående` tills featuren har mergats, eftersom
roadmapens status `Klar` även kräver merge.
Ett nytt ADR behövs endast om biblioteksvalet bedöms vara ett övergripande,
långlivat frontendbeslut som påverkar fler delar av applikationen än Feature
6. Om `dnd-kit` endast används lokalt för denna feature bör beslutet normalt
dokumenteras i feature- och arkitekturdokumentationen.
## Acceptanskriterier
Feature 6 är klar när:
- kort kan dras mellan olika statuskolumner;
- dragningen använder Feature 5:s status-API;
- kortet flyttas optimistiskt till målkolumnen;
- kortet tonas ned under serveranropet;
- samma kort är låst för status, dragning och tilldelning under anropet;
- andra kort förblir interaktiva;
- otilldelad uppgift till Pågående använder aktiv användares id;
- befintlig ansvarig behålls;
- serverresponsen ersätter det optimistiska tillståndet;
- fel återställer hela tidigare task-versionen;
- kortet återgår till ursprungskolumnen vid fel;
- felet visas lokalt på kortet;
- drop i samma kolumn är no-op;
- avbruten dragning är no-op;
- ingen manuell eller persistent kortordning har införts;
- statusknapparna fungerar fortsatt men är arkitekturellt frikopplade;
- statusknapparna förblir serverbekräftade medan drag-and-drop är optimistisk;
- statusknapparna kan tas bort senare utan att drag- eller statuslogiken byggs
om;
- kortets icke-interaktiva yta fungerar som dragyta;
- interaktiva kortkontroller initierar inte dragning;
- ett senare draghandtag kan införas utan ändring av statusflödet;
- normal vertikal scrollning på mobil bevaras;
- ingen horisontell scrollning har införts;
- desktop fungerar väl;
- touch fungerar på rimlig grundnivå;
- tangentbordsdragning inte krävs;
- statusändring fortsatt är möjlig med tangentbord genom statusknapparna;
- frontendtesterna täcker centrala stateövergångar och felfall;
- manuell verifiering täcker dragkänsla, touch, layout och rollback;
- backend är oförändrad om inget konkret behov av justering hittas;
- relevant dokumentation är uppdaterad.
## Implementationsprinciper
Vid implementationen användes följande dokumentation som tekniskt underlag:
```text
AGENTS.md
README.md
docs/architecture.md
docs/development.md
docs/roadmap.md
docs/decisions/
docs/features/004-task-assignment.md
docs/features/005-task-status.md
```
Relevant frontendkod och tester granskades särskilt avseende:
- aktuell React-version;
- frontendens installerade beroenden;
- aktuell officiell dnd-kit-dokumentation;
- vilka dnd-kit-paket som är aktuella respektive legacy;
- dnd-kit-lösningens kompatibilitet med React-versionen;
- påverkan på Vitest/jsdom och React Testing Library;
- aktuell task-typ;
- brädans kolumnstruktur;
- uppgiftskortets komponentstruktur;
- befintliga statusknappar;
- statusanropets requestformat;
- hur aktiv användare representeras;
- hur tasks ersätts i state;
- det gemensamma vänteläget per kort;
- status- och tilldelningsfel;
- tilldelningskontrollens inaktiveringslogik;
- aktuell teststil;
- vilka pointer- och draghändelser testmiljön stödjer.
Repositoryts faktiska kod och dokumentation har företräde framför antaganden i
denna featurebeskrivning.
Kod, tester och relevant dokumentation ska uppdateras tillsammans.
Codex ska inte committa, pusha, skapa pull request eller merga utan uttrycklig
instruktion.

View File

@ -34,20 +34,31 @@ Följande statusvärden används:
## Nuvarande läge ## Nuvarande läge
Feature 02 är klara. Den aktuella applikationen har: Feature 05 är klara. Feature 6 är implementerad och verifierad på sin
feature-branch men ännu inte mergad. Den aktuella applikationen på
feature-branchen har:
- ett monorepo med separat React/Vite-frontend och Spring Boot-backend; - ett monorepo med separat React/Vite-frontend och Spring Boot-backend;
- centralt lagrade användare och ett lokalt browserval av aktiv användare; - centralt lagrade användare och ett lokalt browserval av aktiv användare;
- gemensamma uppgifter med titel, valfri beskrivning och status; - gemensamma uppgifter med titel, valfri beskrivning, status och poäng;
- skapande och listning av uppgifter; - skapande och listning av uppgifter;
- valfri tilldelning av högst en ansvarig användare per uppgift;
- tilldelning och byte av ansvarig i samtliga statusar;
- borttagning av ansvarig i `WAITING` och `COMPLETED`;
- backendstyrda statusändringar mellan `WAITING`, `IN_PROGRESS` och `COMPLETED`;
- automatisk tilldelning till aktiv användare när en otilldelad uppgift påbörjas;
- drag-and-drop mellan statuskolumner med optimistisk flytt och rollback;
- en bräda med Väntande, Pågående och Klart; - en bräda med Väntande, Pågående och Klart;
- nya uppgifter som alltid skapas med status `WAITING`. - nya uppgifter som alltid skapas med status `WAITING`.
Det finns ännu inga poäng, uppgiftstilldelningar, statusändringar, Tilldelning och status är separata egenskaper; tilldelningsflödet ändrar inte
drag-and-drop, redigeringar, raderingar, deadlines eller återkommande uppgifter. uppgiftens status. Alla direkta statusövergångar är tillåtna och
Nuvarande användarval är inte autentisering. `IN_PROGRESS` kräver ansvarig. Det finns ännu ingen redigering, radering,
deadline eller återkommande uppgift. Nuvarande användarval är inte
autentisering.
**Feature 3 Uppgiftspoäng är nästa planerade produktfeature.** **Feature 6 Drag-and-drop är färdig och verifierad på feature-branchen men
står kvar som Pågående tills den har mergats.**
## Featureöversikt ## Featureöversikt
@ -56,10 +67,10 @@ Nuvarande användarval är inte autentisering.
| 0 Projektgrund | Klar | | Körbar frontend, backend och lokal API-koppling | | 0 Projektgrund | Klar | | Körbar frontend, backend och lokal API-koppling |
| 1 Användarval | Klar | 0 | Centrala användare och lokalt aktivt användar-id | | 1 Användarval | Klar | 0 | Centrala användare och lokalt aktivt användar-id |
| 2 Skapa uppgifter | Klar | 01 | Gemensamma uppgifter och trekolumnsbräda | | 2 Skapa uppgifter | Klar | 01 | Gemensamma uppgifter och trekolumnsbräda |
| 3 Uppgiftspoäng | Planerad | 2 | Poäng på uppgifter | | 3 Uppgiftspoäng | Klar | 2 | Poäng på uppgifter |
| 4 Tilldelning | Planerad | 12 | Valfri ansvarig användare | | 4 Tilldelning | Klar | 12 | Valfri ansvarig användare |
| 5 Statusändring | Planerad | 4 | Backendstyrda statusövergångar | | 5 Statusändring | Klar | 4 | Backendstyrda statusövergångar |
| 6 Drag-and-drop | Planerad | 5 | Kortflytt via status-API | | 6 Drag-and-drop | Pågående | 5 | Kortflytt via status-API |
| 7 Radera uppgift | Planerad | 2 | Bekräftad radering | | 7 Radera uppgift | Planerad | 2 | Bekräftad radering |
| 8 Redigera uppgift | Planerad | 3 | Titel, beskrivning och poäng | | 8 Redigera uppgift | Planerad | 3 | Titel, beskrivning och poäng |
| 9 Deadline | Planerad | 2 | Valfri deadline och förseningsmarkering | | 9 Deadline | Planerad | 2 | Valfri deadline och förseningsmarkering |
@ -109,7 +120,7 @@ interaktiv brädhantering införs.
### Feature 3 Uppgiftspoäng ### Feature 3 Uppgiftspoäng
**Status:** Planerad **Status:** Klar
**Beroenden:** Feature 2 **Beroenden:** Feature 2
@ -124,14 +135,13 @@ interaktiv brädhantering införs.
Feature 3 ligger först eftersom poäng blir ett centralt uppgiftsfält som senare Feature 3 ligger först eftersom poäng blir ett centralt uppgiftsfält som senare
ska kunna redigeras och historikföras. ska kunna redigeras och historikföras.
**Öppna frågor:** Poängskalan är beslutad till alla heltal mellan 1 och 99. V3-migreringen ger
eventuella befintliga uppgifter värdet `1` innan kolumnen görs obligatorisk;
- exakt poängskala; databasen har inget permanent defaultvärde.
- standardvärde för befintliga uppgifter.
### Feature 4 Tilldelning av uppgifter ### Feature 4 Tilldelning av uppgifter
**Status:** Planerad **Status:** Klar
**Beroenden:** Feature 1 och Feature 2 **Beroenden:** Feature 1 och Feature 2
@ -142,17 +152,14 @@ ska kunna redigeras och historikföras.
- visa ansvarig på uppgiftskort; - visa ansvarig på uppgiftskort;
- kunna ändra ansvarig på en befintlig uppgift. - kunna ändra ansvarig på en befintlig uppgift.
En väntande uppgift får vara tilldelad eller otilldelad. Tilldelning införs före En väntande uppgift får vara tilldelad eller otilldelad och har högst en
statusändring eftersom en pågående uppgift senare måste ha en ansvarig. ansvarig. Tilldelning införs före statusändring eftersom en pågående uppgift
senare måste ha en ansvarig. Hur borttagna användare ska hanteras är fortsatt
**Öppna frågor:** öppet tills användarradering införs.
- om en uppgift ska ha endast en ansvarig;
- hur borttagna användare ska hanteras när användarradering införs.
### Feature 5 Statusändring och statusregler ### Feature 5 Statusändring och statusregler
**Status:** Planerad **Status:** Klar
**Beroenden:** Feature 4 **Beroenden:** Feature 4
@ -165,17 +172,17 @@ statusändring eftersom en pågående uppgift senare måste ha en ansvarig.
`IN_PROGRESS` kräver en ansvarig användare. Statusflödet införs före `IN_PROGRESS` kräver en ansvarig användare. Statusflödet införs före
drag-and-drop så att affärsregeln och API:t kan verifieras utan att samtidigt drag-and-drop så att affärsregeln och API:t kan verifieras utan att samtidigt
bygga en komplex interaktion. bygga en komplex interaktion. Feature 5 återanvänder Feature 4:s
tilldelningsmodell och särskilda API för ansvarig; statusändring sker i ett
separat statusflöde.
**Öppna frågor:** En otilldelad uppgift som sätts till `IN_PROGRESS` tilldelas automatiskt den
aktiva browseranvändaren. En befintlig ansvarig behålls. Ansvarig kan bytas men
- vad som sker när en otilldelad uppgift sätts till `IN_PROGRESS`; inte tas bort medan uppgiften är pågående.
- om aktiv användare ska föreslås automatiskt;
- vad som sker om ansvarig tas bort från en pågående uppgift.
### Feature 6 Drag-and-drop ### Feature 6 Drag-and-drop
**Status:** Planerad **Status:** Pågående
**Beroenden:** Feature 5 **Beroenden:** Feature 5
@ -189,10 +196,14 @@ bygga en komplex interaktion.
Drag-and-drop kommer efter det enklare statusflödet för att återanvända Drag-and-drop kommer efter det enklare statusflödet för att återanvända
verifierade backendregler. verifierade backendregler.
**Öppna frågor:** Drag-and-drop använder en kontrollerad optimistisk flytt. Vid fel återställs
hela den tidigare task-versionen. En otilldelad uppgift som dras till Pågående
- optimistisk eller serverbekräftad uppdatering; använder Feature 5:s befintliga automatiska tilldelning till aktiv användare.
- exakt tilldelningsflöde vid flytt till Pågående. Serverns fullständiga task-respons ersätter alltid det optimistiska värdet.
Statusknapparna förblir tills vidare serverbekräftade. Implementation och
automatisk samt manuell verifiering är färdiga på feature-branchen; statusen
förblir `Pågående` tills merge eftersom `Klar` enligt roadmapen även kräver att
featuren är mergad.
## Fas 2 Hantering av uppgifter ## Fas 2 Hantering av uppgifter
@ -422,8 +433,6 @@ Nuvarande aktiva användarval är uttryckligen inte autentisering.
## Öppna tvärgående frågor ## Öppna tvärgående frågor
- Vilken poängskala ska användas och vilket standardvärde får befintliga
uppgifter?
- Ska uppgifter raderas permanent eller mjukt? - Ska uppgifter raderas permanent eller mjukt?
- Hur ska datum, tider och tidszoner representeras? - Hur ska datum, tider och tidszoner representeras?
- Ska H2 behållas för lokal utveckling efter PostgreSQL-införandet? - Ska H2 behållas för lokal utveckling efter PostgreSQL-införandet?
@ -435,5 +444,12 @@ Nuvarande aktiva användarval är uttryckligen inte autentisering.
## Ändringshistorik ## Ändringshistorik
- 2026-07-27: Feature 6 implementerades och verifierades automatiskt och
manuellt på feature-branchen. Den behåller statusen Pågående tills merge.
- 2026-07-27: Feature 5 verifierades och mergades. Backendstyrda
statusövergångar, automatisk tilldelning vid påbörjande och statusberoende
tilldelningsregler infördes. Feature 6 blev nästa planerade produktfeature.
- 2026-07-26: Feature 3 och Feature 4 markerades som klara efter verifiering och
merge. Feature 5 blev nästa planerade produktfeature.
- 2026-07-26: Roadmapen etablerades. Feature 02 markerades som klara, Feature - 2026-07-26: Roadmapen etablerades. Feature 02 markerades som klara, Feature
316 planerades och Feature 17 markerades som villkorad. 316 planerades och Feature 17 markerades som villkorad.

View File

@ -9,6 +9,8 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/react": "0.5.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },

View File

@ -8,6 +8,12 @@ importers:
.: .:
dependencies: dependencies:
'@dnd-kit/dom':
specifier: 0.5.0
version: 0.5.0
'@dnd-kit/react':
specifier: 0.5.0
version: 0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react: react:
specifier: 19.2.8 specifier: 19.2.8
version: 19.2.8 version: 19.2.8
@ -115,6 +121,27 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'} engines: {node: '>=20.19.0'}
'@dnd-kit/abstract@0.5.0':
resolution: {integrity: sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA==}
'@dnd-kit/collision@0.5.0':
resolution: {integrity: sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ==}
'@dnd-kit/dom@0.5.0':
resolution: {integrity: sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw==}
'@dnd-kit/geometry@0.5.0':
resolution: {integrity: sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A==}
'@dnd-kit/react@0.5.0':
resolution: {integrity: sha512-abQPLI8lmfVE+v/n+pqy5WFxrw6T2Yg0UQZsL78dp5DKci7dKTVDjvLWqvass+XTFtzJmsZEjk1NdqE6xG8Jiw==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
'@dnd-kit/state@0.5.0':
resolution: {integrity: sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w==}
'@emnapi/core@1.11.1': '@emnapi/core@1.11.1':
resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
@ -145,6 +172,9 @@ packages:
'@oxc-project/types@0.139.0': '@oxc-project/types@0.139.0':
resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==}
'@preact/signals-core@1.14.4':
resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==}
'@rolldown/binding-android-arm64@1.1.5': '@rolldown/binding-android-arm64@1.1.5':
resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -961,6 +991,45 @@ snapshots:
'@csstools/css-tokenizer@4.0.0': {} '@csstools/css-tokenizer@4.0.0': {}
'@dnd-kit/abstract@0.5.0':
dependencies:
'@dnd-kit/geometry': 0.5.0
'@dnd-kit/state': 0.5.0
tslib: 2.8.1
'@dnd-kit/collision@0.5.0':
dependencies:
'@dnd-kit/abstract': 0.5.0
'@dnd-kit/geometry': 0.5.0
tslib: 2.8.1
'@dnd-kit/dom@0.5.0':
dependencies:
'@dnd-kit/abstract': 0.5.0
'@dnd-kit/collision': 0.5.0
'@dnd-kit/geometry': 0.5.0
'@dnd-kit/state': 0.5.0
tslib: 2.8.1
'@dnd-kit/geometry@0.5.0':
dependencies:
'@dnd-kit/state': 0.5.0
tslib: 2.8.1
'@dnd-kit/react@0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@dnd-kit/abstract': 0.5.0
'@dnd-kit/dom': 0.5.0
'@dnd-kit/state': 0.5.0
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
tslib: 2.8.1
'@dnd-kit/state@0.5.0':
dependencies:
'@preact/signals-core': 1.14.4
tslib: 2.8.1
'@emnapi/core@1.11.1': '@emnapi/core@1.11.1':
dependencies: dependencies:
'@emnapi/wasi-threads': 1.2.2 '@emnapi/wasi-threads': 1.2.2
@ -990,6 +1059,8 @@ snapshots:
'@oxc-project/types@0.139.0': {} '@oxc-project/types@0.139.0': {}
'@preact/signals-core@1.14.4': {}
'@rolldown/binding-android-arm64@1.1.5': '@rolldown/binding-android-arm64@1.1.5':
optional: true optional: true
@ -1476,8 +1547,7 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
tslib@2.8.1: tslib@2.8.1: {}
optional: true
typescript@7.0.2: typescript@7.0.2:
optionalDependencies: optionalDependencies:

View File

@ -1,7 +1,33 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import type { ReactNode } from 'react'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, expect, test, vi } from 'vitest' import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import App from './App' import App from './App'
const dragAndDrop = vi.hoisted(() => ({
onTaskDrop: null as ((taskId: string, status: string) => void) | null,
}))
vi.mock('./TaskDragAndDrop', () => ({
TaskDragDropProvider: ({
children,
onTaskDrop,
}: {
children: ReactNode
onTaskDrop: (taskId: string, status: string) => void
}) => {
dragAndDrop.onTaskDrop = onTaskDrop
return children
},
useTaskDraggable: () => ({
ref: () => {},
isDragging: false,
}),
useTaskColumnDropTarget: () => ({
ref: () => {},
isDropTarget: false,
}),
}))
const users = [ const users = [
{ {
id: 'd56b54dd-31b0-4d71-8a10-82464be59a61', id: 'd56b54dd-31b0-4d71-8a10-82464be59a61',
@ -21,6 +47,8 @@ const tasks = [
title: 'Dammsuga', title: 'Dammsuga',
description: 'Bottenvåningen', description: 'Bottenvåningen',
status: 'WAITING', status: 'WAITING',
points: 7,
assignee: null,
createdAt: '2026-07-24T10:00:00Z', createdAt: '2026-07-24T10:00:00Z',
}, },
{ {
@ -28,6 +56,8 @@ const tasks = [
title: 'Diska', title: 'Diska',
description: null, description: null,
status: 'IN_PROGRESS', status: 'IN_PROGRESS',
points: 3,
assignee: { id: users[1].id, name: users[1].name },
createdAt: '2026-07-24T10:01:00Z', createdAt: '2026-07-24T10:01:00Z',
}, },
{ {
@ -35,12 +65,15 @@ const tasks = [
title: 'Vattna blommor', title: 'Vattna blommor',
description: null, description: null,
status: 'COMPLETED', status: 'COMPLETED',
points: 5,
assignee: null,
createdAt: '2026-07-24T10:02:00Z', createdAt: '2026-07-24T10:02:00Z',
}, },
] ]
beforeEach(() => { beforeEach(() => {
window.localStorage.clear() window.localStorage.clear()
dragAndDrop.onTaskDrop = null
}) })
afterEach(() => { afterEach(() => {
@ -167,12 +200,14 @@ test('brädan visar tre kolumner och grupperar hämtade uppgifter', async () =>
render(<App />) render(<App />)
await screen.findByText('Dammsuga')
const waiting = await screen.findByRole('region', { name: 'Väntande' }) const waiting = await screen.findByRole('region', { name: 'Väntande' })
const inProgress = screen.getByRole('region', { name: 'Pågående' }) const inProgress = screen.getByRole('region', { name: 'Pågående' })
const completed = screen.getByRole('region', { name: 'Klart' }) const completed = screen.getByRole('region', { name: 'Klart' })
expect(within(waiting).getByText('Dammsuga')).toBeInTheDocument() expect(within(waiting).getByText('Dammsuga')).toBeInTheDocument()
expect(within(waiting).getByText('Bottenvåningen')).toBeInTheDocument() expect(within(waiting).getByText('Bottenvåningen')).toBeInTheDocument()
expect(within(waiting).getByText('7 p')).toBeInTheDocument()
expect(within(inProgress).getByText('Diska')).toBeInTheDocument() expect(within(inProgress).getByText('Diska')).toBeInTheDocument()
expect(within(completed).getByText('Vattna blommor')).toBeInTheDocument() expect(within(completed).getByText('Vattna blommor')).toBeInTheDocument()
expect(screen.queryByText(/Inga uppgifter/i)).not.toBeInTheDocument() expect(screen.queryByText(/Inga uppgifter/i)).not.toBeInTheDocument()
@ -187,6 +222,14 @@ test('Ny uppgift öppnar modalen med fokus i titelfältet', async () => {
expect(screen.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument() expect(screen.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument()
expect(screen.getByLabelText('Titel')).toHaveFocus() expect(screen.getByLabelText('Titel')).toHaveFocus()
expect(screen.getByLabelText('Poäng')).toHaveValue(1)
expect(screen.getByLabelText('Tilldela')).toHaveValue('')
expect(within(screen.getByLabelText('Tilldela')).getByRole('option', { name: 'Ingen' }))
.toBeInTheDocument()
expect(within(screen.getByLabelText('Tilldela')).getByRole('option', { name: 'Urban' }))
.toBeInTheDocument()
expect(within(screen.getByLabelText('Tilldela')).getByRole('option', { name: 'Anna' }))
.toBeInTheDocument()
}) })
test.each([ test.each([
@ -220,6 +263,7 @@ test.each([
fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), { fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), {
target: { value: 'Bottenvåningen' }, target: { value: 'Bottenvåningen' },
}) })
fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: '7' } })
close() close()
@ -229,6 +273,7 @@ test.each([
expect(screen.getByLabelText('Titel')).toHaveValue('') expect(screen.getByLabelText('Titel')).toHaveValue('')
expect(screen.getByLabelText('Beskrivning (valfri)')).toHaveValue('') expect(screen.getByLabelText('Beskrivning (valfri)')).toHaveValue('')
expect(screen.getByLabelText('Poäng')).toHaveValue(1)
}) })
test('en skapad uppgift visas längst ned i Väntande och modalen stängs', async () => { test('en skapad uppgift visas längst ned i Väntande och modalen stängs', async () => {
@ -237,6 +282,8 @@ test('en skapad uppgift visas längst ned i Väntande och modalen stängs', asyn
title: 'Putsa fönster', title: 'Putsa fönster',
description: 'Köket', description: 'Köket',
status: 'WAITING', status: 'WAITING',
points: 7,
assignee: { id: users[1].id, name: users[1].name },
createdAt: '2026-07-24T10:03:00Z', createdAt: '2026-07-24T10:03:00Z',
} }
window.localStorage.setItem('hemhub.activeUserId', users[0].id) window.localStorage.setItem('hemhub.activeUserId', users[0].id)
@ -253,21 +300,551 @@ test('en skapad uppgift visas längst ned i Väntande och modalen stängs', asyn
fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), { fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), {
target: { value: ' Köket ' }, target: { value: ' Köket ' },
}) })
fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: '7' } })
fireEvent.change(screen.getByLabelText('Tilldela'), { target: { value: users[1].id } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' })) fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' }))
await waitFor(() => await waitFor(() =>
expect(screen.queryByRole('dialog', { name: 'Skapa ny uppgift' })).not.toBeInTheDocument(), expect(screen.queryByRole('dialog', { name: 'Skapa ny uppgift' })).not.toBeInTheDocument(),
) )
const waiting = screen.getByRole('region', { name: 'Väntande' }) const waiting = await screen.findByRole('region', { name: 'Väntande' })
expect(within(waiting).getAllByRole('article').map((card) => card.textContent)).toEqual([ expect(
'DammsugaBottenvåningen', within(waiting)
'Putsa fönsterKöket', .getAllByRole('article')
]) .map((card) => within(card).getByRole('heading').textContent),
).toEqual(['Dammsuga', 'Putsa fönster'])
expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', { expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Putsa fönster', description: 'Köket' }), body: JSON.stringify({
title: 'Putsa fönster',
description: 'Köket',
points: 7,
assigneeId: users[1].id,
}),
}) })
fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' }))
expect(screen.getByLabelText('Poäng')).toHaveValue(1)
expect(screen.getByLabelText('Tilldela')).toHaveValue('')
})
test('Ingen skickas som null när en uppgift skapas', async () => {
const createdTask = {
...tasks[0],
id: '00000000-0000-0000-0000-000000000010',
title: 'Torka bordet',
}
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([]))
fetchMock.mockResolvedValueOnce(jsonResponse(createdTask, 201))
render(<App />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' }))
fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Torka bordet' } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' }))
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3))
expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Torka bordet',
description: null,
points: 1,
assigneeId: null,
}),
})
})
test('statusanrop byter inte en befintlig ansvarig', async () => {
const assignedWaiting = {
...tasks[0],
assignee: { id: users[1].id, name: users[1].name },
}
const updatedTask = { ...assignedWaiting, status: 'IN_PROGRESS' }
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([assignedWaiting]))
fetchMock.mockResolvedValueOnce(jsonResponse(updatedTask))
render(<App />)
const card = (await screen.findByText('Dammsuga')).closest('article')!
fireEvent.click(within(card).getByRole('button', { name: 'Påbörja' }))
const inProgress = screen.getByRole('region', { name: 'Pågående' })
expect(await within(inProgress).findByText('Anna')).toBeInTheDocument()
expect(within(inProgress).queryByText('Urban')).not.toBeInTheDocument()
})
test('alla statusar har redigerbar tilldelning med statusberoende alternativ', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
mockUsersAndTasks(users, tasks)
render(<App />)
const waitingCard = (await screen.findByText('Dammsuga')).closest('article')
const inProgressCard = screen.getByText('Diska').closest('article')
const completedCard = screen.getByText('Vattna blommor').closest('article')
expect(waitingCard).not.toBeNull()
expect(inProgressCard).not.toBeNull()
expect(completedCard).not.toBeNull()
expect(within(waitingCard!).getByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
.toHaveTextContent('Ta uppgift')
expect(within(inProgressCard!).getByText('Anna')).toBeInTheDocument()
expect(within(completedCard!).getByText('Otilldelad')).toBeInTheDocument()
fireEvent.click(
within(inProgressCard!).getByRole('button', { name: 'Ändra ansvarig för Diska' }),
)
expect(
within(inProgressCard!).queryByRole('option', { name: 'Ingen' }),
).not.toBeInTheDocument()
fireEvent.click(
within(completedCard!).getByRole('button', {
name: 'Ändra ansvarig för Vattna blommor',
}),
)
expect(within(completedCard!).getByRole('option', { name: 'Ingen' })).toBeInTheDocument()
})
test('visar rätt statusknappar för varje kolumn', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
mockUsersAndTasks(users, tasks)
render(<App />)
const waitingCard = (await screen.findByText('Dammsuga')).closest('article')!
const inProgressCard = screen.getByText('Diska').closest('article')!
const completedCard = screen.getByText('Vattna blommor').closest('article')!
expect(within(waitingCard).getByRole('button', { name: 'Påbörja' })).toBeInTheDocument()
expect(within(waitingCard).getByRole('button', { name: 'Markera klar' })).toBeInTheDocument()
expect(within(inProgressCard).getByRole('button', { name: 'Till Väntande' }))
.toBeInTheDocument()
expect(within(inProgressCard).getByRole('button', { name: 'Markera klar' }))
.toBeInTheDocument()
expect(within(completedCard).getByRole('button', { name: 'Till Väntande' }))
.toBeInTheDocument()
expect(within(completedCard).getByRole('button', { name: 'Påbörja igen' }))
.toBeInTheDocument()
})
test.each([
{ task: tasks[0], button: 'Påbörja', target: 'IN_PROGRESS' },
{ task: tasks[0], button: 'Markera klar', target: 'COMPLETED' },
{ task: tasks[1], button: 'Till Väntande', target: 'WAITING' },
{ task: tasks[1], button: 'Markera klar', target: 'COMPLETED' },
{ task: tasks[2], button: 'Till Väntande', target: 'WAITING' },
{ task: tasks[2], button: 'Påbörja igen', target: 'IN_PROGRESS' },
])('$button skickar status $target', async ({ task, button, target }) => {
const updatedTask = {
...task,
status: target,
assignee:
target === 'IN_PROGRESS' && !task.assignee
? { id: users[0].id, name: users[0].name }
: task.assignee,
}
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([task]))
fetchMock.mockResolvedValueOnce(jsonResponse(updatedTask))
render(<App />)
const card = (await screen.findByText(task.title)).closest('article')!
fireEvent.click(within(card).getByRole('button', { name: button }))
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3))
expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${task.id}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: target,
...(target === 'IN_PROGRESS' ? { activeUserId: users[0].id } : {}),
}),
})
})
test('status uppdateras först efter serversvar och låser endast berört kort', async () => {
const otherTask = {
...tasks[0],
id: '00000000-0000-0000-0000-000000000010',
title: 'Putsa fönster',
}
const updatedTask = {
...tasks[0],
status: 'IN_PROGRESS',
assignee: { id: users[0].id, name: users[0].name },
}
let resolveStatus!: (response: Response) => void
const statusResponse = new Promise<Response>((resolve) => {
resolveStatus = resolve
})
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0], otherTask]))
fetchMock.mockReturnValueOnce(statusResponse)
render(<App />)
const waiting = await screen.findByRole('region', { name: 'Väntande' })
const card = (await within(waiting).findByText('Dammsuga')).closest('article')!
const otherCard = within(waiting).getByText('Putsa fönster').closest('article')!
const startButton = within(card).getByRole('button', { name: 'Påbörja' })
fireEvent.click(startButton)
fireEvent.click(startButton)
expect(within(waiting).getByText('Dammsuga')).toBeInTheDocument()
expect(startButton).toBeDisabled()
expect(within(card).getByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
.toBeDisabled()
expect(within(otherCard).getByRole('button', { name: 'Påbörja' })).toBeEnabled()
expect(fetchMock).toHaveBeenCalledTimes(3)
resolveStatus(jsonResponse(updatedTask))
const inProgress = screen.getByRole('region', { name: 'Pågående' })
expect(await within(inProgress).findByText('Dammsuga')).toBeInTheDocument()
expect(within(inProgress).getByText('Urban')).toBeInTheDocument()
})
test('statusfel behåller tidigare status och ansvarig och visas på kortet', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]]))
fetchMock.mockResolvedValueOnce(
jsonResponse(
{
code: 'TASK_REQUIRES_ASSIGNEE',
message: 'En pågående uppgift måste ha en ansvarig.',
},
409,
),
)
render(<App />)
const waiting = await screen.findByRole('region', { name: 'Väntande' })
const card = (await within(waiting).findByText('Dammsuga')).closest('article')!
fireEvent.click(within(card).getByRole('button', { name: 'Påbörja' }))
expect(await within(card).findByRole('alert')).toHaveTextContent(
'En pågående uppgift måste ha en ansvarig.',
)
expect(within(waiting).getByText('Dammsuga')).toBeInTheDocument()
expect(within(card).getByText('Ta uppgift')).toBeInTheDocument()
})
test('drag flyttar optimistiskt, låser kortet och använder hela serverresponsen', async () => {
const otherTask = {
...tasks[0],
id: '00000000-0000-0000-0000-000000000010',
title: 'Putsa fönster',
}
const serverTask = {
...tasks[0],
status: 'COMPLETED',
assignee: { id: users[1].id, name: users[1].name },
}
let resolveStatus!: (response: Response) => void
const statusResponse = new Promise<Response>((resolve) => {
resolveStatus = resolve
})
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0], otherTask]))
fetchMock.mockReturnValueOnce(statusResponse)
render(<App />)
await screen.findByText('Dammsuga')
act(() => dropTask(tasks[0].id, 'IN_PROGRESS'))
const inProgress = screen.getByRole('region', { name: 'Pågående' })
const optimisticCard = (await within(inProgress).findByText('Dammsuga')).closest('article')!
const waiting = screen.getByRole('region', { name: 'Väntande' })
const otherCard = within(waiting).getByText('Putsa fönster').closest('article')!
expect(within(optimisticCard).getByText('Urban')).toBeInTheDocument()
expect(optimisticCard).toHaveAttribute('aria-busy', 'true')
expect(optimisticCard).toHaveClass('task-card-pending')
expect(within(optimisticCard).getByRole('button', { name: 'Markera klar' })).toBeDisabled()
expect(
within(optimisticCard).getByRole('button', { name: 'Ändra ansvarig för Dammsuga' }),
).toBeDisabled()
expect(within(otherCard).getByRole('button', { name: 'Påbörja' })).toBeEnabled()
expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: 'IN_PROGRESS',
activeUserId: users[0].id,
}),
})
act(() => dropTask(tasks[0].id, 'COMPLETED'))
expect(fetchMock).toHaveBeenCalledTimes(3)
await act(async () => resolveStatus(jsonResponse(serverTask)))
const completed = screen.getByRole('region', { name: 'Klart' })
const confirmedCard = (await within(completed).findByText('Dammsuga')).closest('article')!
expect(within(confirmedCard).getByText('Anna')).toBeInTheDocument()
expect(confirmedCard).not.toHaveAttribute('aria-busy')
})
test('dragfel återställer hela uppgiften och visar lokalt fel', async () => {
let resolveStatus!: (response: Response) => void
const statusResponse = new Promise<Response>((resolve) => {
resolveStatus = resolve
})
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]]))
fetchMock.mockReturnValueOnce(statusResponse)
render(<App />)
await screen.findByText('Dammsuga')
act(() => dropTask(tasks[0].id, 'IN_PROGRESS'))
const inProgress = screen.getByRole('region', { name: 'Pågående' })
expect(await within(inProgress).findByText('Urban')).toBeInTheDocument()
await act(async () =>
resolveStatus(
jsonResponse(
{
code: 'TASK_REQUIRES_ASSIGNEE',
message: 'En pågående uppgift måste ha en ansvarig.',
},
409,
),
),
)
const waiting = screen.getByRole('region', { name: 'Väntande' })
const restoredCard = (await within(waiting).findByText('Dammsuga')).closest('article')!
expect(within(restoredCard).getByText('Ta uppgift')).toBeInTheDocument()
expect(within(restoredCard).queryByText('Urban')).not.toBeInTheDocument()
expect(await within(restoredCard).findByRole('alert')).toHaveTextContent(
'En pågående uppgift måste ha en ansvarig.',
)
expect(restoredCard).not.toHaveAttribute('aria-busy')
})
test('drag till Pågående behåller en befintlig ansvarig optimistiskt', async () => {
const assignedTask = {
...tasks[0],
assignee: { id: users[1].id, name: users[1].name },
}
let resolveStatus!: (response: Response) => void
const statusResponse = new Promise<Response>((resolve) => {
resolveStatus = resolve
})
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([assignedTask]))
fetchMock.mockReturnValueOnce(statusResponse)
render(<App />)
await screen.findByText('Dammsuga')
act(() => dropTask(assignedTask.id, 'IN_PROGRESS'))
const inProgress = screen.getByRole('region', { name: 'Pågående' })
expect(await within(inProgress).findByText('Anna')).toBeInTheDocument()
expect(within(inProgress).queryByText('Urban')).not.toBeInTheDocument()
await act(async () =>
resolveStatus(jsonResponse({ ...assignedTask, status: 'IN_PROGRESS' })),
)
})
test('drop i samma kolumn är no-op', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = mockUsersAndTasks(users, [tasks[0]])
render(<App />)
await screen.findByText('Dammsuga')
act(() => dropTask(tasks[0].id, 'WAITING'))
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(screen.getByText('Dammsuga').closest('article')).not.toHaveAttribute('aria-busy')
})
test('olika kort kan ha samtidiga optimistiska statusanrop', async () => {
const otherTask = {
...tasks[0],
id: '00000000-0000-0000-0000-000000000010',
title: 'Putsa fönster',
}
const firstResponse = new Promise<Response>(() => {})
const secondResponse = new Promise<Response>(() => {})
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0], otherTask]))
fetchMock.mockReturnValueOnce(firstResponse)
fetchMock.mockReturnValueOnce(secondResponse)
render(<App />)
await screen.findByText('Dammsuga')
act(() => {
dropTask(tasks[0].id, 'IN_PROGRESS')
dropTask(otherTask.id, 'COMPLETED')
})
expect(screen.getByText('Dammsuga').closest('article')).toHaveAttribute('aria-busy', 'true')
expect(screen.getByText('Putsa fönster').closest('article')).toHaveAttribute(
'aria-busy',
'true',
)
expect(fetchMock).toHaveBeenCalledTimes(4)
})
test('ansvarig kan bytas i Pågående och tas bort i Klart', async () => {
const changedInProgress = { ...tasks[1], assignee: { id: users[0].id, name: users[0].name } }
const unassignedCompleted = { ...tasks[2], assignee: null }
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[1], { ...tasks[2], assignee: tasks[1].assignee }]))
fetchMock.mockResolvedValueOnce(jsonResponse(changedInProgress))
fetchMock.mockResolvedValueOnce(jsonResponse(unassignedCompleted))
render(<App />)
const inProgressCard = (await screen.findByText('Diska')).closest('article')!
fireEvent.click(
within(inProgressCard).getByRole('button', { name: 'Ändra ansvarig för Diska' }),
)
fireEvent.change(within(inProgressCard).getByRole('combobox'), {
target: { value: users[0].id },
})
await waitFor(() =>
expect(within(inProgressCard).getByRole('button', { name: 'Ändra ansvarig för Diska' }))
.toHaveTextContent('Urban'),
)
const completedCard = screen.getByText('Vattna blommor').closest('article')!
fireEvent.click(
within(completedCard).getByRole('button', {
name: 'Ändra ansvarig för Vattna blommor',
}),
)
fireEvent.change(within(completedCard).getByRole('combobox'), { target: { value: '' } })
await waitFor(() =>
expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[2].id}/assignee`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assigneeId: null }),
}),
)
})
test('val av ansvarig anropar endpointen och uppdaterar kortet efter svar', async () => {
const updatedTask = { ...tasks[0], assignee: { id: users[1].id, name: users[1].name } }
let resolveAssignment!: (response: Response) => void
const assignmentResponse = new Promise<Response>((resolve) => {
resolveAssignment = resolve
})
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]]))
fetchMock.mockReturnValueOnce(assignmentResponse)
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
const select = screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' })
fireEvent.change(select, { target: { value: users[1].id } })
expect(select).toBeDisabled()
expect(within(select.closest('article')!).getByRole('button', { name: 'Påbörja' }))
.toBeDisabled()
expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/assignee`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assigneeId: users[1].id }),
})
resolveAssignment(jsonResponse(updatedTask))
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
.toHaveTextContent('Anna'),
)
expect(screen.queryByRole('combobox', { name: 'Ansvarig för Dammsuga' })).not.toBeInTheDocument()
})
test('val av Ingen av-tilldelar en väntande uppgift', async () => {
const assignedTask = { ...tasks[0], assignee: { id: users[1].id, name: users[1].name } }
const unassignedTask = { ...assignedTask, assignee: null }
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([assignedTask]))
fetchMock.mockResolvedValueOnce(jsonResponse(unassignedTask))
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
fireEvent.change(screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' }), {
target: { value: '' },
})
await screen.findByText('Ta uppgift')
expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/assignee`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assigneeId: null }),
})
})
test('misslyckad tilldelning behåller ansvarig och visar fel', async () => {
const assignedTask = { ...tasks[0], assignee: { id: users[1].id, name: users[1].name } }
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([assignedTask]))
fetchMock.mockResolvedValueOnce(
jsonResponse({ code: 'USER_NOT_FOUND', message: 'Användaren finns inte.' }, 404),
)
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
fireEvent.change(screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' }), {
target: { value: users[0].id },
})
expect(await screen.findByRole('alert')).toHaveTextContent('Användaren finns inte.')
expect(screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' })).toHaveValue(users[1].id)
})
test.each([
{ värde: '', beskrivning: 'tomt' },
{ värde: '0', beskrivning: 'under 1' },
{ värde: '100', beskrivning: 'över 99' },
{ värde: '1.5', beskrivning: 'decimaltal' },
])('ogiltigt poängvärde ($beskrivning) blockerar submit', async ({ värde }) => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = mockUsersAndTasks(users, [])
render(<App />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' }))
fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Dammsuga' } })
fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: värde } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'Poäng måste vara ett heltal mellan 1 och 99.',
)
expect(fetchMock).toHaveBeenCalledTimes(2)
}) })
test('formulärdata bevaras när skapande av uppgift misslyckas', async () => { test('formulärdata bevaras när skapande av uppgift misslyckas', async () => {
@ -285,14 +862,20 @@ test('formulärdata bevaras när skapande av uppgift misslyckas', async () => {
fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' })) fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' }))
const title = screen.getByLabelText('Titel') const title = screen.getByLabelText('Titel')
const description = screen.getByLabelText('Beskrivning (valfri)') const description = screen.getByLabelText('Beskrivning (valfri)')
const points = screen.getByLabelText('Poäng')
const assignee = screen.getByLabelText('Tilldela')
fireEvent.change(title, { target: { value: 'Dammsuga' } }) fireEvent.change(title, { target: { value: 'Dammsuga' } })
fireEvent.change(description, { target: { value: 'Bottenvåningen' } }) fireEvent.change(description, { target: { value: 'Bottenvåningen' } })
fireEvent.change(points, { target: { value: '7' } })
fireEvent.change(assignee, { target: { value: users[1].id } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' })) fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Uppgiften är ogiltig.') expect(await screen.findByRole('alert')).toHaveTextContent('Uppgiften är ogiltig.')
expect(screen.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument() expect(screen.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument()
expect(title).toHaveValue('Dammsuga') expect(title).toHaveValue('Dammsuga')
expect(description).toHaveValue('Bottenvåningen') expect(description).toHaveValue('Bottenvåningen')
expect(points).toHaveValue(7)
expect(assignee).toHaveValue(users[1].id)
}) })
function mockUsersAndTasks(userResponse: unknown, taskResponse: unknown) { function mockUsersAndTasks(userResponse: unknown, taskResponse: unknown) {
@ -302,6 +885,14 @@ function mockUsersAndTasks(userResponse: unknown, taskResponse: unknown) {
return fetchMock return fetchMock
} }
function dropTask(taskId: string, status: string) {
if (!dragAndDrop.onTaskDrop) {
throw new Error('Drag-and-drop-providern är inte monterad')
}
dragAndDrop.onTaskDrop(taskId, status)
}
function mockJsonResponse(body: unknown, status = 200) { function mockJsonResponse(body: unknown, status = 200) {
return vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(body, status)) return vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(body, status))
} }

View File

@ -79,7 +79,14 @@ function App() {
} }
if (activeUser) { if (activeUser) {
return <TaskBoard activeUserName={activeUser.name} onLogOut={logOut} /> return (
<TaskBoard
activeUserId={activeUser.id}
activeUserName={activeUser.name}
users={users}
onLogOut={logOut}
/>
)
} }
if (showCreateUser) { if (showCreateUser) {

View File

@ -1,12 +1,25 @@
import { FormEvent, MouseEvent, useEffect, useRef, useState } from 'react' import { FormEvent, MouseEvent, useEffect, useRef, useState } from 'react'
import {
TaskDragDropProvider,
TaskStatus,
useTaskColumnDropTarget,
useTaskDraggable,
} from './TaskDragAndDrop'
type TaskStatus = 'WAITING' | 'IN_PROGRESS' | 'COMPLETED' type UserSummary = {
id: string
name: string
}
type Assignee = UserSummary
type Task = { type Task = {
id: string id: string
title: string title: string
description: string | null description: string | null
status: TaskStatus status: TaskStatus
points: number
assignee: Assignee | null
createdAt: string createdAt: string
} }
@ -15,7 +28,9 @@ type ApiError = {
} }
type TaskBoardProps = { type TaskBoardProps = {
activeUserId: string
activeUserName: string activeUserName: string
users: UserSummary[]
onLogOut: () => void onLogOut: () => void
} }
@ -25,10 +40,14 @@ const columns: { status: TaskStatus; title: string }[] = [
{ status: 'COMPLETED', title: 'Klart' }, { status: 'COMPLETED', title: 'Klart' },
] ]
function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) { function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardProps) {
const [tasks, setTasks] = useState<Task[]>([]) const [tasks, setTasks] = useState<Task[]>([])
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading') const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
const [showCreateTask, setShowCreateTask] = useState(false) const [showCreateTask, setShowCreateTask] = useState(false)
const [editingAssigneeTaskId, setEditingAssigneeTaskId] = useState<string | null>(null)
const [pendingTaskIds, setPendingTaskIds] = useState<Set<string>>(new Set())
const [taskErrors, setTaskErrors] = useState<Record<string, string>>({})
const pendingTaskIdsRef = useRef(new Set<string>())
const loadTasks = async () => { const loadTasks = async () => {
setLoadState('loading') setLoadState('loading')
@ -51,6 +70,130 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
void loadTasks() void loadTasks()
}, []) }, [])
const beginTaskRequest = (taskId: string) => {
if (pendingTaskIdsRef.current.has(taskId)) {
return false
}
pendingTaskIdsRef.current.add(taskId)
setPendingTaskIds(new Set(pendingTaskIdsRef.current))
setTaskErrors((current) => ({ ...current, [taskId]: '' }))
return true
}
const finishTaskRequest = (taskId: string) => {
pendingTaskIdsRef.current.delete(taskId)
setPendingTaskIds(new Set(pendingTaskIdsRef.current))
}
const replaceTask = (updatedTask: Task) => {
setTasks((current) =>
current.map((task) => (task.id === updatedTask.id ? updatedTask : task)),
)
}
const updateAssignee = async (task: Task, assigneeId: string) => {
if (!beginTaskRequest(task.id)) {
return
}
try {
const response = await fetch(`/api/tasks/${task.id}/assignee`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assigneeId: assigneeId || null }),
})
if (!response.ok) {
const apiError = (await response.json().catch(() => ({}))) as ApiError
setTaskErrors((current) => ({
...current,
[task.id]: apiError.message ?? 'Det gick inte att ändra ansvarig. Försök igen.',
}))
return
}
const updatedTask = (await response.json()) as Task
replaceTask(updatedTask)
setEditingAssigneeTaskId(null)
} catch {
setTaskErrors((current) => ({
...current,
[task.id]: 'Det gick inte att ändra ansvarig. Försök igen.',
}))
} finally {
finishTaskRequest(task.id)
}
}
const updateStatus = async (
task: Task,
status: TaskStatus,
presentation: 'server-confirmed' | 'optimistic',
) => {
if (!beginTaskRequest(task.id)) {
return
}
const previousTask = task
if (presentation === 'optimistic') {
replaceTask({
...task,
status,
assignee:
status === 'IN_PROGRESS' && !task.assignee
? { id: activeUserId, name: activeUserName }
: task.assignee,
})
}
try {
const response = await fetch(`/api/tasks/${task.id}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status,
...(status === 'IN_PROGRESS' ? { activeUserId } : {}),
}),
})
if (!response.ok) {
const apiError = (await response.json().catch(() => ({}))) as ApiError
if (presentation === 'optimistic') {
replaceTask(previousTask)
}
setTaskErrors((current) => ({
...current,
[task.id]: apiError.message ?? 'Det gick inte att ändra status. Försök igen.',
}))
return
}
replaceTask((await response.json()) as Task)
setEditingAssigneeTaskId(null)
} catch {
if (presentation === 'optimistic') {
replaceTask(previousTask)
}
setTaskErrors((current) => ({
...current,
[task.id]: 'Det gick inte att ändra status. Försök igen.',
}))
} finally {
finishTaskRequest(task.id)
}
}
const dropTask = (taskId: string, status: TaskStatus) => {
const task = tasks.find((candidate) => candidate.id === taskId)
if (!task || task.status === status) {
return
}
void updateStatus(task, status, 'optimistic')
}
return ( return (
<main className="task-app"> <main className="task-app">
<header className="app-header"> <header className="app-header">
@ -83,26 +226,29 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
</button> </button>
</div> </div>
<section className="board" aria-label="Uppgiftsbräda"> <TaskDragDropProvider onTaskDrop={dropTask}>
{columns.map((column) => ( <section className="board" aria-label="Uppgiftsbräda">
<section className="board-column" key={column.status} aria-labelledby={column.status}> {columns.map((column) => (
<h2 id={column.status}>{column.title}</h2> <TaskColumn
<div className="task-list"> column={column}
{tasks tasks={tasks.filter((task) => task.status === column.status)}
.filter((task) => task.status === column.status) users={users}
.map((task) => ( editingAssigneeTaskId={editingAssigneeTaskId}
<article className="task-card" key={task.id}> pendingTaskIds={pendingTaskIds}
<h3>{task.title}</h3> taskErrors={taskErrors}
{task.description && <p>{task.description}</p>} onEditAssignee={setEditingAssigneeTaskId}
</article> onChangeAssignee={(task, assigneeId) => void updateAssignee(task, assigneeId)}
))} onChangeStatus={(task, status) =>
</div> void updateStatus(task, status, 'server-confirmed')
</section> }
))} />
</section> ))}
</section>
</TaskDragDropProvider>
{showCreateTask && ( {showCreateTask && (
<CreateTaskModal <CreateTaskModal
users={users}
onClose={() => setShowCreateTask(false)} onClose={() => setShowCreateTask(false)}
onCreated={(task) => { onCreated={(task) => {
setTasks((currentTasks) => [...currentTasks, task]) setTasks((currentTasks) => [...currentTasks, task])
@ -114,14 +260,240 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
) )
} }
type TaskColumnProps = {
column: { status: TaskStatus; title: string }
tasks: Task[]
users: UserSummary[]
editingAssigneeTaskId: string | null
pendingTaskIds: Set<string>
taskErrors: Record<string, string>
onEditAssignee: (taskId: string) => void
onChangeAssignee: (task: Task, assigneeId: string) => void
onChangeStatus: (task: Task, status: TaskStatus) => void
}
function TaskColumn({
column,
tasks,
users,
editingAssigneeTaskId,
pendingTaskIds,
taskErrors,
onEditAssignee,
onChangeAssignee,
onChangeStatus,
}: TaskColumnProps) {
const { ref, isDropTarget } = useTaskColumnDropTarget(column.status)
return (
<section
ref={ref}
className={`board-column${isDropTarget ? ' board-column-drop-target' : ''}`}
aria-labelledby={column.status}
>
<h2 id={column.status}>{column.title}</h2>
<div className="task-list">
{tasks.map((task) => (
<TaskCard
key={task.id}
task={task}
users={users}
editingAssignee={editingAssigneeTaskId === task.id}
pending={pendingTaskIds.has(task.id)}
error={taskErrors[task.id]}
onEditAssignee={() => onEditAssignee(task.id)}
onChangeAssignee={(assigneeId) => onChangeAssignee(task, assigneeId)}
onChangeStatus={(status) => onChangeStatus(task, status)}
/>
))}
</div>
</section>
)
}
type TaskCardProps = {
task: Task
users: UserSummary[]
editingAssignee: boolean
pending: boolean
error?: string
onEditAssignee: () => void
onChangeAssignee: (assigneeId: string) => void
onChangeStatus: (status: TaskStatus) => void
}
function TaskCard({
task,
users,
editingAssignee,
pending,
error,
onEditAssignee,
onChangeAssignee,
onChangeStatus,
}: TaskCardProps) {
const { ref, isDragging } = useTaskDraggable(task.id, pending)
return (
<article
ref={ref}
role="article"
className={`task-card${pending ? ' task-card-pending' : ''}${
isDragging ? ' task-card-dragging' : ''
}`}
aria-busy={pending || undefined}
>
<div className="task-card-header">
<h3>{task.title}</h3>
<span className="points-badge">{task.points} p</span>
</div>
{task.description && <p>{task.description}</p>}
<AssigneeControl
task={task}
users={users}
editing={editingAssignee}
pending={pending}
onEdit={onEditAssignee}
onChange={onChangeAssignee}
/>
<TaskStatusControls task={task} disabled={pending} onChange={onChangeStatus} />
{error && (
<p className="task-error error" role="alert">
{error}
</p>
)}
</article>
)
}
type AssigneeControlProps = {
task: Task
users: UserSummary[]
editing: boolean
pending: boolean
onEdit: () => void
onChange: (assigneeId: string) => void
}
function UserIcon() {
return (
<svg
className="user-icon"
viewBox="0 0 24 24"
width="18"
height="18"
aria-hidden="true"
>
<circle cx="12" cy="8" r="3.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path
d="M5 20c.5-4 3-6 7-6s6.5 2 7 6"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
)
}
function AssigneeControl({
task,
users,
editing,
pending,
onEdit,
onChange,
}: AssigneeControlProps) {
const displayName =
task.assignee?.name ?? (task.status === 'WAITING' ? 'Ta uppgift' : 'Otilldelad')
return (
<div className="task-assignment">
{editing ? (
<label className="assignee-select-label">
<span className="visually-hidden">Ansvarig för {task.title}</span>
<UserIcon />
<select
aria-label={`Ansvarig för ${task.title}`}
value={task.assignee?.id ?? ''}
disabled={pending}
autoFocus
onChange={(event) => onChange(event.target.value)}
>
{task.status !== 'IN_PROGRESS' && <option value="">Ingen</option>}
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</label>
) : (
<button
type="button"
className="task-assignee task-assignee-button"
disabled={pending}
onClick={onEdit}
aria-label={`Ändra ansvarig för ${task.title}`}
>
<UserIcon />
<span>{displayName}</span>
</button>
)}
</div>
)
}
type TaskStatusControlsProps = {
task: Task
disabled: boolean
onChange: (status: TaskStatus) => void
}
const statusActions: Record<TaskStatus, { label: string; target: TaskStatus }[]> = {
WAITING: [
{ label: 'Påbörja', target: 'IN_PROGRESS' },
{ label: 'Markera klar', target: 'COMPLETED' },
],
IN_PROGRESS: [
{ label: 'Till Väntande', target: 'WAITING' },
{ label: 'Markera klar', target: 'COMPLETED' },
],
COMPLETED: [
{ label: 'Till Väntande', target: 'WAITING' },
{ label: 'Påbörja igen', target: 'IN_PROGRESS' },
],
}
function TaskStatusControls({ task, disabled, onChange }: TaskStatusControlsProps) {
return (
<div className="task-status-actions" aria-label={`Ändra status för ${task.title}`}>
{statusActions[task.status].map((action) => (
<button
type="button"
className="status-button"
key={action.target}
disabled={disabled}
onClick={() => onChange(action.target)}
>
{action.label}
</button>
))}
</div>
)
}
type CreateTaskModalProps = { type CreateTaskModalProps = {
users: UserSummary[]
onClose: () => void onClose: () => void
onCreated: (task: Task) => void onCreated: (task: Task) => void
} }
function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) { function CreateTaskModal({ users, onClose, onCreated }: CreateTaskModalProps) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [points, setPoints] = useState('1')
const [assigneeId, setAssigneeId] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const isSubmittingRef = useRef(false) const isSubmittingRef = useRef(false)
@ -152,6 +524,7 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
const trimmedTitle = title.trim() const trimmedTitle = title.trim()
const trimmedDescription = description.trim() const trimmedDescription = description.trim()
const numericPoints = Number(points)
if (!trimmedTitle || [...trimmedTitle].length > 100) { if (!trimmedTitle || [...trimmedTitle].length > 100) {
setError('Titeln måste innehålla mellan 1 och 100 tecken.') setError('Titeln måste innehålla mellan 1 och 100 tecken.')
@ -163,6 +536,16 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
return return
} }
if (
!points.trim() ||
!Number.isInteger(numericPoints) ||
numericPoints < 1 ||
numericPoints > 99
) {
setError('Poäng måste vara ett heltal mellan 1 och 99.')
return
}
setError('') setError('')
isSubmittingRef.current = true isSubmittingRef.current = true
setIsSubmitting(true) setIsSubmitting(true)
@ -174,6 +557,8 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
body: JSON.stringify({ body: JSON.stringify({
title: trimmedTitle, title: trimmedTitle,
description: trimmedDescription || null, description: trimmedDescription || null,
points: numericPoints,
assigneeId: assigneeId || null,
}), }),
}) })
@ -212,7 +597,7 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
× ×
</button> </button>
</div> </div>
<form onSubmit={(event) => void submit(event)}> <form noValidate onSubmit={(event) => void submit(event)}>
<label htmlFor="task-title">Titel</label> <label htmlFor="task-title">Titel</label>
<input <input
id="task-title" id="task-title"
@ -231,6 +616,39 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
onChange={(event) => setDescription(event.target.value)} onChange={(event) => setDescription(event.target.value)}
/> />
<label htmlFor="task-points">Poäng</label>
<input
id="task-points"
type="number"
min="1"
max="99"
step="1"
value={points}
disabled={isSubmitting}
aria-describedby="task-points-help"
onChange={(event) => setPoints(event.target.value)}
/>
<p id="task-points-help" className="field-help">
199 poäng beroende hur tidskrävande, besvärlig eller viktig uppgiften är.
</p>
<label className="field-label-uppercase" htmlFor="task-assignee">
Tilldela
</label>
<select
id="task-assignee"
value={assigneeId}
disabled={isSubmitting}
onChange={(event) => setAssigneeId(event.target.value)}
>
<option value="">Ingen</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
{error && ( {error && (
<p className="error" role="alert"> <p className="error" role="alert">
{error} {error}

View File

@ -0,0 +1,18 @@
import { expect, test } from 'vitest'
import { resolveTaskDrop } from './TaskDragAndDrop'
test.each(['WAITING', 'IN_PROGRESS', 'COMPLETED'] as const)(
'mappar målkolumnen %s till motsvarande status',
(status) => {
expect(resolveTaskDrop('task-1', status, false)).toEqual({
taskId: 'task-1',
targetStatus: status,
})
},
)
test('avbruten dragning och ogiltig målkolumn är no-op', () => {
expect(resolveTaskDrop('task-1', 'WAITING', true)).toBeNull()
expect(resolveTaskDrop('task-1', undefined, false)).toBeNull()
expect(resolveTaskDrop('task-1', 'UNKNOWN', false)).toBeNull()
})

View File

@ -0,0 +1,90 @@
import { ReactNode } from 'react'
import { DragDropProvider, useDraggable, useDroppable } from '@dnd-kit/react'
import { PointerActivationConstraints, PointerSensor } from '@dnd-kit/dom'
export type TaskStatus = 'WAITING' | 'IN_PROGRESS' | 'COMPLETED'
type TaskDragDropProviderProps = {
children: ReactNode
onTaskDrop: (taskId: string, targetStatus: TaskStatus) => void
}
const taskStatuses = new Set<TaskStatus>(['WAITING', 'IN_PROGRESS', 'COMPLETED'])
const pointerSensor = PointerSensor.configure({
activationConstraints(event) {
if (event.pointerType === 'touch') {
return [new PointerActivationConstraints.Delay({ value: 250, tolerance: 8 })]
}
return [new PointerActivationConstraints.Distance({ value: 6 })]
},
})
export function resolveTaskDrop(
sourceId: string | number | undefined,
targetId: string | number | undefined,
canceled: boolean,
) {
if (
canceled ||
sourceId === undefined ||
typeof targetId !== 'string' ||
!taskStatuses.has(targetId as TaskStatus)
) {
return null
}
return {
taskId: String(sourceId),
targetStatus: targetId as TaskStatus,
}
}
export function TaskDragDropProvider({
children,
onTaskDrop,
}: TaskDragDropProviderProps) {
return (
<DragDropProvider
sensors={(defaults) => [
...defaults.filter((sensor) => sensor !== PointerSensor),
pointerSensor,
]}
onDragEnd={(event) => {
const drop = resolveTaskDrop(
event.operation.source?.id,
event.operation.target?.id,
event.canceled,
)
if (!drop) {
return
}
onTaskDrop(drop.taskId, drop.targetStatus)
}}
>
{children}
</DragDropProvider>
)
}
export function useTaskDraggable(taskId: string, disabled: boolean) {
const { ref, isDragging } = useDraggable({
id: taskId,
type: 'task',
disabled,
})
return { ref, isDragging }
}
export function useTaskColumnDropTarget(status: TaskStatus) {
const { ref, isDropTarget } = useDroppable({
id: status,
accept: 'task',
})
return { ref, isDropTarget }
}

View File

@ -23,6 +23,7 @@ h1 {
button, button,
input, input,
select,
textarea { textarea {
font: inherit; font: inherit;
} }
@ -38,6 +39,7 @@ button {
button:disabled, button:disabled,
input:disabled, input:disabled,
select:disabled,
textarea:disabled { textarea:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.65; opacity: 0.65;
@ -70,6 +72,15 @@ input {
border-radius: 0.4rem; border-radius: 0.4rem;
} }
select {
box-sizing: border-box;
width: 100%;
padding: 0.6rem;
border: 1px solid #9ca3af;
border-radius: 0.4rem;
background: white;
}
textarea { textarea {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
@ -147,8 +158,15 @@ textarea {
.board-column { .board-column {
min-height: 20rem; min-height: 20rem;
padding: 1rem; padding: 1rem;
border: 1px solid transparent;
border-radius: 0.75rem; border-radius: 0.75rem;
background: #e5e7eb; background: #e5e7eb;
transition: border-color 120ms ease, background-color 120ms ease;
}
.board-column-drop-target {
border-color: #93c5fd;
background: #e0e7ff;
} }
.board-column h2 { .board-column h2 {
@ -169,17 +187,121 @@ textarea {
box-shadow: 0 0.125rem 0.4rem rgb(0 0 0 / 8%); box-shadow: 0 0.125rem 0.4rem rgb(0 0 0 / 8%);
} }
.task-card-pending {
opacity: 0.65;
}
.task-card-dragging {
cursor: grabbing;
}
.task-card h3, .task-card h3,
.task-card p { .task-card p {
margin: 0; margin: 0;
} }
.task-assignment {
margin-top: 0.9rem;
}
.task-assignee {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: #475569;
font-size: 0.9rem;
}
.task-assignee-button {
padding: 0.25rem 0;
color: #2563eb;
background: transparent;
}
.user-icon {
flex: 0 0 auto;
}
.assignee-select-label {
display: flex;
align-items: center;
gap: 0.4rem;
}
.assignee-select-label select {
width: auto;
min-width: 9rem;
}
.field-label-uppercase {
color: #64748b;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.task-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.points-badge {
flex: 0 0 auto;
padding: 0.2rem 0.5rem;
border-radius: 999px;
color: #1e3a8a;
background: #dbeafe;
font-size: 0.8rem;
font-weight: 700;
line-height: 1.25;
}
.task-card p { .task-card p {
margin-top: 0.5rem; margin-top: 0.5rem;
color: #475569; color: #475569;
white-space: pre-wrap; white-space: pre-wrap;
} }
.task-status-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.9rem;
}
.status-button {
padding: 0.4rem 0.65rem;
color: #1e3a8a;
background: #dbeafe;
font-size: 0.85rem;
}
.task-error {
margin-top: 0.6rem;
font-size: 0.85rem;
}
.field-help {
margin: -0.25rem 0 0;
color: #64748b;
font-size: 0.85rem;
}
.modal-backdrop { .modal-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;

View File

@ -1,5 +1,15 @@
import '@testing-library/jest-dom/vitest' import '@testing-library/jest-dom/vitest'
class ResizeObserverStub implements ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserverStub
const storedValues = new Map<string, string>() const storedValues = new Map<string, string>()
Object.defineProperty(window, 'localStorage', { Object.defineProperty(window, 'localStorage', {