7 Commits

31 changed files with 1777 additions and 71 deletions

View File

@ -10,7 +10,7 @@ Backend använder en lokal H2-databas i minnet. Databasschemat hanteras med
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
att skapa och lista gemensamma hushållsuppgifter.
att skapa, lista, tilldela och ändra status på gemensamma hushållsuppgifter.
## Starta backend

View File

@ -2,10 +2,16 @@ package se.rubble.hemhub.api;
import org.springframework.http.HttpStatus;
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.RestControllerAdvice;
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.UserNameAlreadyExistsException;
@ -33,5 +39,47 @@ public class ApiExceptionHandler {
return ResponseEntity.badRequest()
.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

@ -2,7 +2,11 @@ package se.rubble.hemhub.task;
import tools.jackson.databind.JsonNode;
public record CreateTaskRequest(String title, String description, JsonNode points) {
public record CreateTaskRequest(
String title,
String description,
JsonNode points,
JsonNode assigneeId) {
Integer integerPoints() {
if (points == null || !points.isIntegralNumber() || !points.canConvertToInt()) {
@ -11,4 +15,8 @@ public record CreateTaskRequest(String title, String description, JsonNode point
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.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.FetchType;
import se.rubble.hemhub.user.User;
@Entity
@Table(name = "task")
@ -30,6 +34,10 @@ class Task {
@Column(nullable = false)
private int points;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "assignee_id")
private User assignee;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@ -42,17 +50,22 @@ class Task {
String description,
TaskStatus status,
int points,
User assignee,
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.title = title;
this.description = description;
this.status = status;
this.points = points;
this.assignee = assignee;
this.createdAt = createdAt;
}
@ -76,6 +89,29 @@ class Task {
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() {
return createdAt;
}

View File

@ -1,10 +1,13 @@
package se.rubble.hemhub.task;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
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.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
@ -28,9 +31,38 @@ public class TaskController {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public TaskResponse create(@RequestBody(required = false) CreateTaskRequest request) {
UUIDValue assigneeId = request == null
? UUIDValue.optional(null)
: request.parsedAssigneeId();
return taskService.create(
request == null ? null : request.title(),
request == null ? null : request.description(),
request == null ? null : request.integerPoints());
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 org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.EntityGraph;
interface TaskRepository extends JpaRepository<Task, UUID> {
@EntityGraph(attributePaths = "assignee")
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

@ -9,6 +9,7 @@ public record TaskResponse(
String description,
TaskStatus status,
int points,
AssigneeResponse assignee,
Instant createdAt) {
static TaskResponse from(Task task) {
@ -18,6 +19,14 @@ public record TaskResponse(
task.getDescription(),
task.getStatus(),
task.getPoints(),
AssigneeResponse.from(task.getAssignee()),
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.transaction.annotation.Transactional;
import se.rubble.hemhub.user.User;
import se.rubble.hemhub.user.UserRepository;
@Service
class TaskService {
private final TaskRepository taskRepository;
private final UserRepository userRepository;
private final Clock clock;
@Autowired
TaskService(TaskRepository taskRepository) {
this(taskRepository, Clock.systemUTC());
TaskService(TaskRepository taskRepository, UserRepository userRepository) {
this(taskRepository, userRepository, Clock.systemUTC());
}
TaskService(TaskRepository taskRepository, Clock clock) {
TaskService(TaskRepository taskRepository, UserRepository userRepository, Clock clock) {
this.taskRepository = taskRepository;
this.userRepository = userRepository;
this.clock = clock;
}
@ -36,7 +41,8 @@ class TaskService {
TaskResponse create(
String requestedTitle,
String requestedDescription,
Integer requestedPoints) {
Integer requestedPoints,
UUID requestedAssigneeId) {
String title = requestedTitle == null ? "" : requestedTitle.trim();
String description = normalizeDescription(requestedDescription);
@ -55,17 +61,58 @@ class TaskService {
"Poäng måste vara ett heltal mellan 1 och 99.");
}
User assignee = findAssignee(requestedAssigneeId);
Task task = new Task(
UUID.randomUUID(),
title,
description,
TaskStatus.WAITING,
requestedPoints,
assignee,
Instant.now(clock));
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) {
if (requestedDescription == null) {
return null;

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
@Table(name = "app_user")
class User {
public class User {
@Id
private UUID id;
@ -34,11 +34,11 @@ class User {
this.createdAt = createdAt;
}
UUID getId() {
public UUID getId() {
return id;
}
String getName() {
public String getName() {
return name;
}

View File

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

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

@ -15,6 +15,7 @@ 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.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;
@ -52,6 +53,7 @@ class TaskApiTest {
.andExpect(jsonPath("$.description").value("Bottenvåningen"))
.andExpect(jsonPath("$.status").value("WAITING"))
.andExpect(jsonPath("$.points").value(7))
.andExpect(jsonPath("$.assignee").value((Object) null))
.andExpect(jsonPath("$.createdAt").isString());
mockMvc.perform(get("/api/tasks"))
@ -59,6 +61,67 @@ class TaskApiTest {
.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
void storesBlankDescriptionAsNull() throws Exception {
mockMvc.perform(post("/api/tasks")
@ -133,9 +196,12 @@ class TaskApiTest {
UUID secondId = UUID.fromString("00000000-0000-0000-0000-000000000002");
UUID newestId = UUID.fromString("00000000-0000-0000-0000-000000000003");
taskRepository.save(new Task(newestId, "Nyast", null, TaskStatus.WAITING, 3, newer));
taskRepository.save(new Task(secondId, "Andra", null, TaskStatus.IN_PROGRESS, 2, older));
taskRepository.save(new Task(firstId, "Första", null, TaskStatus.COMPLETED, 1, older));
taskRepository.save(new Task(
newestId, "Nyast", null, TaskStatus.WAITING, 3, null, newer));
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"))
.andExpect(status().isOk())
@ -145,6 +211,74 @@ class TaskApiTest {
.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)
@ -159,4 +293,50 @@ class TaskApiTest {
.andExpect(status().isBadRequest())
.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

@ -15,6 +15,20 @@ class TaskTest {
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(),
@ -22,6 +36,7 @@ class TaskTest {
null,
TaskStatus.WAITING,
points,
null,
Instant.parse("2026-07-26T12:00:00Z"));
}
}

View File

@ -29,6 +29,8 @@ Den ansvarar för:
- hämtning och presentation av användare och uppgifter;
- lokalt val av aktiv användare;
- 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;
- klientnära validering och begripliga felmeddelanden;
- uppgiftsbrädan med kolumnerna Väntande, Pågående och Klart.
@ -60,6 +62,8 @@ Aktuella endpoints:
- `POST /api/users`
- `GET /api/tasks`
- `POST /api/tasks`
- `PUT /api/tasks/{taskId}/assignee`
- `PUT /api/tasks/{taskId}/status`
### Databas och migreringar
@ -77,6 +81,7 @@ Flyway kör migreringarna:
- `V1__create_users.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 validerar entiteterna mot det.
@ -104,12 +109,24 @@ En uppgift lagras i tabellen `task` med:
- `description`: valfri beskrivning, högst 500 tecken;
- `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`.
Status lagras som enumens textvärde genom `EnumType.STRING`. Nya uppgifter får
alltid status `WAITING`. Poängintervallet skyddas i backend och med en
databasconstraint. Det finns ingen relation mellan uppgifter och 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
@ -124,12 +141,14 @@ lokalt per browser och utgör inte autentisering eller behörighetskontroll.
### Felhantering
Backend använder ett litet gemensamt JSON-format med `code` och `message`.
`ApiExceptionHandler` översätter kända valideringsfel till `400 Bad Request`
och dubbletter av användarnamn till `409 Conflict`.
`ApiExceptionHandler` översätter kända valideringsfel till `400 Bad Request`,
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
å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;
kortet uppdateras först med backendens bekräftade respons.
### Teststrategi

View File

@ -2,7 +2,7 @@
## Status
Pågående.
Färdig och mergad till `main`.
## Bakgrund
@ -343,7 +343,7 @@ Repositoryts faktiska arkitektur och dokumentation har företräde.
## Backendtester
Feature 3 ska minst verifiera att:
Backendtesterna verifierar att:
- en uppgift kan skapas med ett giltigt `points`;
- det skapade API-svaret innehåller samma `points`;
@ -356,12 +356,12 @@ Feature 3 ska minst verifiera att:
- negativa värden 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
lämpligt.
Testerna följer den befintliga teststilen och utökar de tidigare
uppgifts-API-testerna.
## Frontendtester
Feature 3 ska minst verifiera att:
Frontendtesterna verifierar att:
- skapandedialogen öppnas med poängvärdet `1`;
- ett giltigt poängvärde skickas i create-anropet;
@ -373,11 +373,11 @@ Feature 3 ska minst verifiera att:
- ett uppgiftskort visar uppgiftens dynamiska poängbadge;
- 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
Följande ska verifieras manuellt:
Följande verifierades manuellt:
1. Starta frontend och backend enligt projektets utvecklingsinstruktioner.
2. Skapa en uppgift utan att ändra poängfältet.
@ -436,3 +436,8 @@ Dokumentation, implementation och tester ska uppdateras tillsammans.
Codex ska inte committa, pusha, skapa pull request eller merga utan uttrycklig
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,123 @@
# Feature 5 Statusändring och statusregler
## Status
Färdig och verifierad på feature-branchen; ännu inte 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
Fylls i efter implementation och merge.

View File

@ -34,20 +34,24 @@ Följande statusvärden används:
## Nuvarande läge
Feature 02 är klara. Den aktuella applikationen har:
Feature 04 är klara. Den aktuella applikationen har:
- ett monorepo med separat React/Vite-frontend och Spring Boot-backend;
- centralt lagrade användare och ett lokalt browserval av aktiv användare;
- gemensamma uppgifter med titel, valfri beskrivning, status och poäng;
- skapande och listning av uppgifter;
- valfri tilldelning av högst en ansvarig användare per uppgift;
- tilldelning, byte och borttagning av ansvarig för väntande uppgifter;
- en bräda med Väntande, Pågående och Klart;
- nya uppgifter som alltid skapas med status `WAITING`.
Det finns ännu inga uppgiftstilldelningar, statusändringar, drag-and-drop,
redigeringar, raderingar, deadlines eller återkommande uppgifter.
Tilldelning och status är separata egenskaper; tilldelningsflödet ändrar inte
uppgiftens status. Statusändring och regeln att `IN_PROGRESS` kräver ansvarig
är under utveckling. Det finns ännu ingen drag-and-drop, redigering, radering,
deadline eller återkommande uppgift.
Nuvarande användarval är inte autentisering.
**Feature 3 Uppgiftspoäng är pågående.**
**Feature 5 Statusändring och statusregler är pågående.**
## Featureöversikt
@ -56,9 +60,9 @@ Nuvarande användarval är inte autentisering.
| 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 |
| 2 Skapa uppgifter | Klar | 01 | Gemensamma uppgifter och trekolumnsbräda |
| 3 Uppgiftspoäng | Pågående | 2 | Poäng på uppgifter |
| 4 Tilldelning | Planerad | 12 | Valfri ansvarig användare |
| 5 Statusändring | Planerad | 4 | Backendstyrda statusövergångar |
| 3 Uppgiftspoäng | Klar | 2 | Poäng på uppgifter |
| 4 Tilldelning | Klar | 12 | Valfri ansvarig användare |
| 5 Statusändring | Pågående | 4 | Backendstyrda statusövergångar |
| 6 Drag-and-drop | Planerad | 5 | Kortflytt via status-API |
| 7 Radera uppgift | Planerad | 2 | Bekräftad radering |
| 8 Redigera uppgift | Planerad | 3 | Titel, beskrivning och poäng |
@ -109,7 +113,7 @@ interaktiv brädhantering införs.
### Feature 3 Uppgiftspoäng
**Status:** Pågående
**Status:** Klar
**Beroenden:** Feature 2
@ -130,7 +134,7 @@ databasen har inget permanent defaultvärde.
### Feature 4 Tilldelning av uppgifter
**Status:** Planerad
**Status:** Klar
**Beroenden:** Feature 1 och Feature 2
@ -141,17 +145,14 @@ databasen har inget permanent defaultvärde.
- visa ansvarig på uppgiftskort;
- kunna ändra ansvarig på en befintlig uppgift.
En väntande uppgift får vara tilldelad eller otilldelad. Tilldelning införs före
statusändring eftersom en pågående uppgift senare måste ha en ansvarig.
**Öppna frågor:**
- om en uppgift ska ha endast en ansvarig;
- hur borttagna användare ska hanteras när användarradering införs.
En väntande uppgift får vara tilldelad eller otilldelad och har högst en
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
öppet tills användarradering införs.
### Feature 5 Statusändring och statusregler
**Status:** Planerad
**Status:** Pågående
**Beroenden:** Feature 4
@ -164,13 +165,13 @@ 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
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:**
- vad som sker när en otilldelad uppgift sätts till `IN_PROGRESS`;
- om aktiv användare ska föreslås automatiskt;
- vad som sker om ansvarig tas bort från en pågående uppgift.
En otilldelad uppgift som sätts till `IN_PROGRESS` tilldelas automatiskt den
aktiva browseranvändaren. En befintlig ansvarig behålls. Ansvarig kan bytas men
inte tas bort medan uppgiften är pågående.
### Feature 6 Drag-and-drop
@ -432,5 +433,7 @@ Nuvarande aktiva användarval är uttryckligen inte autentisering.
## Ändringshistorik
- 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
316 planerades och Feature 17 markerades som villkorad.

View File

@ -22,6 +22,7 @@ const tasks = [
description: 'Bottenvåningen',
status: 'WAITING',
points: 7,
assignee: null,
createdAt: '2026-07-24T10:00:00Z',
},
{
@ -30,6 +31,7 @@ const tasks = [
description: null,
status: 'IN_PROGRESS',
points: 3,
assignee: { id: users[1].id, name: users[1].name },
createdAt: '2026-07-24T10:01:00Z',
},
{
@ -38,6 +40,7 @@ const tasks = [
description: null,
status: 'COMPLETED',
points: 5,
assignee: null,
createdAt: '2026-07-24T10:02:00Z',
},
]
@ -171,7 +174,7 @@ test('brädan visar tre kolumner och grupperar hämtade uppgifter', async () =>
render(<App />)
await screen.findByText('Dammsuga')
const waiting = screen.getByRole('region', { name: 'Väntande' })
const waiting = await screen.findByRole('region', { name: 'Väntande' })
const inProgress = screen.getByRole('region', { name: 'Pågående' })
const completed = screen.getByRole('region', { name: 'Klart' })
@ -193,6 +196,13 @@ test('Ny uppgift öppnar modalen med fokus i titelfältet', async () => {
expect(screen.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument()
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([
@ -246,6 +256,7 @@ test('en skapad uppgift visas längst ned i Väntande och modalen stängs', asyn
description: 'Köket',
status: 'WAITING',
points: 7,
assignee: { id: users[1].id, name: users[1].name },
createdAt: '2026-07-24T10:03:00Z',
}
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
@ -263,24 +274,360 @@ test('en skapad uppgift visas längst ned i Väntande och modalen stängs', asyn
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' }))
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: 'Skapa ny uppgift' })).not.toBeInTheDocument(),
)
const waiting = screen.getByRole('region', { name: 'Väntande' })
expect(within(waiting).getAllByRole('article').map((card) => card.textContent)).toEqual([
'Dammsuga7 pBottenvåningen',
'Putsa fönster7 pKöket',
])
const waiting = await screen.findByRole('region', { name: 'Väntande' })
expect(
within(waiting)
.getAllByRole('article')
.map((card) => within(card).getByRole('heading').textContent),
).toEqual(['Dammsuga', 'Putsa fönster'])
expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Putsa fönster', description: 'Köket', points: 7 }),
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('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([
@ -321,9 +668,11 @@ test('formulärdata bevaras när skapande av uppgift misslyckas', async () => {
const title = screen.getByLabelText('Titel')
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(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' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Uppgiften är ogiltig.')
@ -331,6 +680,7 @@ test('formulärdata bevaras när skapande av uppgift misslyckas', async () => {
expect(title).toHaveValue('Dammsuga')
expect(description).toHaveValue('Bottenvåningen')
expect(points).toHaveValue(7)
expect(assignee).toHaveValue(users[1].id)
})
function mockUsersAndTasks(userResponse: unknown, taskResponse: unknown) {

View File

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

View File

@ -2,12 +2,20 @@ import { FormEvent, MouseEvent, useEffect, useRef, useState } from 'react'
type TaskStatus = 'WAITING' | 'IN_PROGRESS' | 'COMPLETED'
type UserSummary = {
id: string
name: string
}
type Assignee = UserSummary
type Task = {
id: string
title: string
description: string | null
status: TaskStatus
points: number
assignee: Assignee | null
createdAt: string
}
@ -16,7 +24,9 @@ type ApiError = {
}
type TaskBoardProps = {
activeUserId: string
activeUserName: string
users: UserSummary[]
onLogOut: () => void
}
@ -26,10 +36,14 @@ const columns: { status: TaskStatus; title: string }[] = [
{ status: 'COMPLETED', title: 'Klart' },
]
function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardProps) {
const [tasks, setTasks] = useState<Task[]>([])
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
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 () => {
setLoadState('loading')
@ -52,6 +66,98 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
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) => {
if (!beginTaskRequest(task.id)) {
return
}
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
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 {
setTaskErrors((current) => ({
...current,
[task.id]: 'Det gick inte att ändra status. Försök igen.',
}))
} finally {
finishTaskRequest(task.id)
}
}
return (
<main className="task-app">
<header className="app-header">
@ -91,15 +197,37 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
<div className="task-list">
{tasks
.filter((task) => task.status === column.status)
.map((task) => (
.map((task) => {
const pending = pendingTaskIds.has(task.id)
return (
<article className="task-card" key={task.id}>
<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={editingAssigneeTaskId === task.id}
pending={pending}
onEdit={() => setEditingAssigneeTaskId(task.id)}
onChange={(assigneeId) => void updateAssignee(task, assigneeId)}
/>
<TaskStatusControls
task={task}
disabled={pending}
onChange={(status) => void updateStatus(task, status)}
/>
{taskErrors[task.id] && (
<p className="task-error error" role="alert">
{taskErrors[task.id]}
</p>
)}
</article>
))}
)
})}
</div>
</section>
))}
@ -107,6 +235,7 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
{showCreateTask && (
<CreateTaskModal
users={users}
onClose={() => setShowCreateTask(false)}
onCreated={(task) => {
setTasks((currentTasks) => [...currentTasks, task])
@ -118,15 +247,134 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
)
}
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 = {
users: UserSummary[]
onClose: () => void
onCreated: (task: Task) => void
}
function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
function CreateTaskModal({ users, onClose, onCreated }: CreateTaskModalProps) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [points, setPoints] = useState('1')
const [assigneeId, setAssigneeId] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const isSubmittingRef = useRef(false)
@ -191,6 +439,7 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
title: trimmedTitle,
description: trimmedDescription || null,
points: numericPoints,
assigneeId: assigneeId || null,
}),
})
@ -264,6 +513,23 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
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 && (
<p className="error" role="alert">
{error}

View File

@ -23,6 +23,7 @@ h1 {
button,
input,
select,
textarea {
font: inherit;
}
@ -38,6 +39,7 @@ button {
button:disabled,
input:disabled,
select:disabled,
textarea:disabled {
cursor: not-allowed;
opacity: 0.65;
@ -70,6 +72,15 @@ input {
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 {
box-sizing: border-box;
width: 100%;
@ -174,6 +185,59 @@ textarea {
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;
@ -198,6 +262,25 @@ textarea {
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;