19 Commits

Author SHA1 Message Date
f699f57e07 merged feature-documents 2026-07-28 14:45:40 +02:00
057758b1e9 Upload files to "docs" 2026-07-28 14:43:02 +02:00
0e42a023fc docs: close feature 8 2026-07-28 12:22:46 +02:00
28258f4c37 Merge pull request 'feat: add task editing' (#12) from feature/008-task-editing into main
Reviewed-on: #12
2026-07-28 11:19:26 +02:00
443f686c20 feat: add task editing 2026-07-28 11:18:13 +02:00
b6460a3924 docs: close feature 7 2026-07-27 22:06:08 +02:00
5df0146672 Merge pull request 'feat: add task deletion' (#11) from feature/007-task-deletion into main
Reviewed-on: #11
2026-07-27 21:27:25 +02:00
f296d15446 feat: add task deletion 2026-07-27 21:26:19 +02:00
5dea4c4027 docs: close feature 6 2026-07-27 13:21:17 +02:00
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
41 changed files with 4028 additions and 838 deletions

View File

@ -10,7 +10,10 @@ 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, redigera, tilldela, ändra status på och permanent radera
gemensamma hushållsuppgifter. Uppgiftskort kan flyttas mellan brädans
statuskolumner med drag-and-drop eller med de befintliga statusknapparna.
Redigering och radering genomförs först när backend har bekräftat operationen.
## 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,40 @@ 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;
}
void changeDetails(String title, String description, int points) {
if (points < 1 || points > 99) {
throw new InvalidTaskException(
"Poäng måste vara ett heltal mellan 1 och 99.");
}
this.title = title;
this.description = description;
this.points = points;
}
Instant getCreatedAt() {
return createdAt;
}

View File

@ -1,10 +1,14 @@
package se.rubble.hemhub.task;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
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 +32,59 @@ 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());
}
@PutMapping("/{taskId}/details")
public TaskResponse updateDetails(
@PathVariable UUID taskId,
@RequestBody(required = false) UpdateTaskDetailsRequest request) {
if (request == null) {
throw new InvalidTaskException("Requesten måste innehålla uppgiftsdetaljer.");
}
return taskService.updateDetails(
taskId,
request.parsedTitle(),
request.parsedDescription(),
request.parsedPoints());
}
@DeleteMapping("/{taskId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable UUID taskId) {
taskService.delete(taskId);
}
}

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,43 +41,118 @@ class TaskService {
TaskResponse create(
String requestedTitle,
String requestedDescription,
Integer requestedPoints) {
String title = requestedTitle == null ? "" : requestedTitle.trim();
String description = normalizeDescription(requestedDescription);
if (title.isEmpty() || codePointLength(title) > 100) {
throw new InvalidTaskException(
"Titeln måste innehålla mellan 1 och 100 tecken.");
}
if (description != null && codePointLength(description) > 500) {
throw new InvalidTaskException(
"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.");
}
Integer requestedPoints,
UUID requestedAssigneeId) {
String title = validateTitle(requestedTitle);
String description = validateDescription(requestedDescription);
int points = validatePoints(requestedPoints);
User assignee = findAssignee(requestedAssigneeId);
Task task = new Task(
UUID.randomUUID(),
title,
description,
TaskStatus.WAITING,
requestedPoints,
points,
assignee,
Instant.now(clock));
return TaskResponse.from(taskRepository.save(task));
}
private static String normalizeDescription(String requestedDescription) {
@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);
}
@Transactional
TaskResponse updateDetails(
UUID taskId,
String requestedTitle,
String requestedDescription,
Integer requestedPoints) {
Task task = taskRepository.findOneById(taskId)
.orElseThrow(TaskNotFoundException::new);
String title = validateTitle(requestedTitle);
String description = validateDescription(requestedDescription);
int points = validatePoints(requestedPoints);
task.changeDetails(title, description, points);
return TaskResponse.from(task);
}
@Transactional
void delete(UUID taskId) {
Task task = taskRepository.findById(taskId)
.orElseThrow(TaskNotFoundException::new);
taskRepository.delete(task);
}
private User findAssignee(UUID requestedAssigneeId) {
if (requestedAssigneeId == null) {
return null;
}
return userRepository.findById(requestedAssigneeId)
.orElseThrow(AssigneeNotFoundException::new);
}
private static String validateTitle(String requestedTitle) {
String title = requestedTitle == null ? "" : requestedTitle.trim();
if (title.isEmpty() || codePointLength(title) > 100) {
throw new InvalidTaskException(
"Titeln måste innehålla mellan 1 och 100 tecken.");
}
return title;
}
private static String validateDescription(String requestedDescription) {
if (requestedDescription == null) {
return null;
}
String description = requestedDescription.trim();
return description.isEmpty() ? null : description;
if (description.isEmpty()) {
return null;
}
if (codePointLength(description) > 500) {
throw new InvalidTaskException(
"Beskrivningen får innehålla högst 500 tecken.");
}
return description;
}
private static int validatePoints(Integer requestedPoints) {
if (requestedPoints == null || requestedPoints < 1 || requestedPoints > 99) {
throw new InvalidTaskException(
"Poäng måste vara ett heltal mellan 1 och 99.");
}
return requestedPoints;
}
private static int codePointLength(String value) {

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,40 @@
package se.rubble.hemhub.task;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.JsonNodeType;
public record UpdateTaskDetailsRequest(
JsonNode title,
JsonNode description,
JsonNode points) {
String parsedTitle() {
if (title == null || title.getNodeType() != JsonNodeType.STRING) {
throw new InvalidTaskException(
"Titeln måste innehålla mellan 1 och 100 tecken.");
}
return title.stringValue();
}
String parsedDescription() {
if (description == null) {
throw new InvalidTaskException("Fältet description måste anges.");
}
if (description.isNull()) {
return null;
}
if (description.getNodeType() != JsonNodeType.STRING) {
throw new InvalidTaskException(
"Beskrivningen får innehålla högst 500 tecken.");
}
return description.stringValue();
}
Integer parsedPoints() {
if (points == null || !points.isIntegralNumber() || !points.canConvertToInt()) {
throw new InvalidTaskException(
"Poäng måste vara ett heltal mellan 1 och 99.");
}
return points.intValue();
}
}

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,128 @@
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.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.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.delete;
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.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
class TaskDeletionApiTest {
@Autowired
private WebApplicationContext context;
@Autowired
private TaskRepository taskRepository;
@Autowired
private UserRepository userRepository;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
taskRepository.deleteAll();
userRepository.deleteAll();
mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
}
@ParameterizedTest
@EnumSource(TaskStatus.class)
void deletesTaskInEveryStatus(TaskStatus statusValue) throws Exception {
User assignee = createUser();
Task task = saveTask(statusValue, assignee);
mockMvc.perform(delete("/api/tasks/{taskId}", task.getId()))
.andExpect(status().isNoContent())
.andExpect(content().string(""));
mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isEmpty());
org.junit.jupiter.api.Assertions.assertTrue(userRepository.existsById(assignee.getId()));
}
@Test
void deletesOnlyRequestedTask() throws Exception {
User assignee = createUser();
Task deleted = saveTask(TaskStatus.WAITING, assignee);
Task remaining = saveTask(TaskStatus.COMPLETED, null);
mockMvc.perform(delete("/api/tasks/{taskId}", deleted.getId()))
.andExpect(status().isNoContent());
mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].id").value(remaining.getId().toString()))
.andExpect(jsonPath("$[0].status").value("COMPLETED"));
org.junit.jupiter.api.Assertions.assertTrue(userRepository.existsById(assignee.getId()));
}
@Test
void returnsNotFoundForUnknownAndAlreadyDeletedTask() throws Exception {
Task task = saveTask(TaskStatus.WAITING, null);
mockMvc.perform(delete("/api/tasks/{taskId}", task.getId()))
.andExpect(status().isNoContent());
mockMvc.perform(delete("/api/tasks/{taskId}", task.getId()))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("TASK_NOT_FOUND"));
mockMvc.perform(delete(
"/api/tasks/{taskId}",
"00000000-0000-0000-0000-000000000099"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("TASK_NOT_FOUND"));
}
@Test
void keepsExistingBadRequestForInvalidUuid() throws Exception {
mockMvc.perform(delete("/api/tasks/{taskId}", "inte-ett-uuid"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT"));
}
private User createUser() throws Exception {
String response = mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "Urban"}
"""))
.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,
Instant.parse("2026-07-28T09:00:00Z")));
}
}

View File

@ -0,0 +1,245 @@
package se.rubble.hemhub.task;
import java.time.Instant;
import java.util.UUID;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
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.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import se.rubble.hemhub.user.User;
import se.rubble.hemhub.user.UserRepository;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
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;
@SpringBootTest
class TaskEditingApiTest {
private static final Instant CREATED_AT = Instant.parse("2026-07-27T09:00:00Z");
@Autowired
private WebApplicationContext context;
@Autowired
private TaskRepository taskRepository;
@Autowired
private UserRepository userRepository;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
taskRepository.deleteAll();
userRepository.deleteAll();
mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
}
@ParameterizedTest
@EnumSource(TaskStatus.class)
void updatesDetailsInEveryStatusAndPreservesOtherFields(TaskStatus statusValue) throws Exception {
User assignee = createUser();
Task task = saveTask(statusValue, assignee, "Före", "Gammal", 3);
mockMvc.perform(put("/api/tasks/{taskId}/details", task.getId())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": " Efter ",
"description": " Ny beskrivning ",
"points": 7
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(task.getId().toString()))
.andExpect(jsonPath("$.title").value("Efter"))
.andExpect(jsonPath("$.description").value("Ny beskrivning"))
.andExpect(jsonPath("$.points").value(7))
.andExpect(jsonPath("$.status").value(statusValue.name()))
.andExpect(jsonPath("$.assignee.id").value(assignee.getId().toString()))
.andExpect(jsonPath("$.assignee.name").value(assignee.getName()))
.andExpect(jsonPath("$.createdAt").value(CREATED_AT.toString()));
Task updated = taskRepository.findOneById(task.getId()).orElseThrow();
assertEquals(statusValue, updated.getStatus());
assertEquals(assignee.getId(), updated.getAssignee().getId());
assertEquals(CREATED_AT, updated.getCreatedAt());
assertTrue(userRepository.existsById(assignee.getId()));
}
@Test
void acceptsBoundariesBlankDescriptionAndUnchangedValues() throws Exception {
String title = "å".repeat(100);
String description = "å".repeat(500);
Task task = saveTask(TaskStatus.WAITING, null, title, description, 1);
updateDetails(task.getId(), title, description, 99)
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value(title))
.andExpect(jsonPath("$.description").value(description))
.andExpect(jsonPath("$.points").value(99));
updateDetails(task.getId(), title, " ", 1)
.andExpect(status().isOk())
.andExpect(jsonPath("$.description").doesNotExist())
.andExpect(jsonPath("$.points").value(1));
updateDetails(task.getId(), title, null, 1)
.andExpect(status().isOk())
.andExpect(jsonPath("$.description").doesNotExist());
}
@ParameterizedTest(name = "{1}")
@MethodSource("invalidRequests")
void rejectsInvalidOrIncompleteRequests(String request, String description) throws Exception {
Task task = saveTask(TaskStatus.WAITING, null, "Före", null, 3);
mockMvc.perform(put("/api/tasks/{taskId}/details", task.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(request))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK"));
}
@Test
void returnsNotFoundAndKeepsOtherTaskUnchanged() throws Exception {
Task otherTask = saveTask(TaskStatus.COMPLETED, null, "Annan", "Oförändrad", 9);
UUID unknownId = UUID.randomUUID();
updateDetails(unknownId, "Ny", null, 4)
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("TASK_NOT_FOUND"));
mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].id").value(otherTask.getId().toString()))
.andExpect(jsonPath("$[0].title").value("Annan"))
.andExpect(jsonPath("$[0].description").value("Oförändrad"))
.andExpect(jsonPath("$[0].points").value(9));
}
@Test
void keepsExistingBadRequestForInvalidUuid() throws Exception {
mockMvc.perform(put("/api/tasks/{taskId}/details", "inte-ett-uuid")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"title": "Ny", "description": null, "points": 4}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT"));
}
private org.springframework.test.web.servlet.ResultActions updateDetails(
UUID taskId,
String title,
String description,
int points) throws Exception {
return mockMvc.perform(put("/api/tasks/{taskId}/details", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": %s,
"description": %s,
"points": %d
}
""".formatted(jsonString(title), jsonString(description), points)));
}
private User createUser() throws Exception {
String response = mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "Urban"}
"""))
.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,
String title,
String description,
int points) {
return taskRepository.save(new Task(
UUID.randomUUID(),
title,
description,
statusValue,
points,
assignee,
CREATED_AT));
}
private static Stream<Arguments> invalidRequests() {
return Stream.of(
Arguments.of("{}", "alla fält saknas"),
Arguments.of("""
{"description": null, "points": 1}
""", "titel saknas"),
Arguments.of("""
{"title": null, "description": null, "points": 1}
""", "titel är null"),
Arguments.of("""
{"title": " ", "description": null, "points": 1}
""", "titel är blank"),
Arguments.of("""
{"title": "%s", "description": null, "points": 1}
""".formatted("a".repeat(101)), "titel är för lång"),
Arguments.of("""
{"title": "Titel", "description": "%s", "points": 1}
""".formatted("a".repeat(501)), "beskrivning är för lång"),
Arguments.of("""
{"title": "Titel", "points": 1}
""", "beskrivning saknas"),
Arguments.of("""
{"title": "Titel", "description": null}
""", "poäng saknas"),
Arguments.of("""
{"title": "Titel", "description": null, "points": null}
""", "poäng är null"),
Arguments.of("""
{"title": "Titel", "description": null, "points": "7"}
""", "poäng är text"),
Arguments.of("""
{"title": "Titel", "description": null, "points": 1.5}
""", "poäng är decimal"),
Arguments.of("""
{"title": "Titel", "description": null, "points": 0}
""", "poäng är noll"),
Arguments.of("""
{"title": "Titel", "description": null, "points": -1}
""", "poäng är negativ"),
Arguments.of("""
{"title": "Titel", "description": null, "points": 100}
""", "poäng är för hög"));
}
private static String jsonString(String value) {
if (value == null) {
return "null";
}
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
}

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

@ -5,6 +5,7 @@ import java.util.UUID;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class TaskTest {
@ -15,6 +16,39 @@ 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")));
}
@Test
void changesOnlyEditableDetailsAndProtectsPointsInvariant() {
Task task = taskWithPoints(3);
UUID id = task.getId();
TaskStatus status = task.getStatus();
Instant createdAt = task.getCreatedAt();
task.changeDetails("Ny titel", "Ny beskrivning", 7);
assertEquals(id, task.getId());
assertEquals("Ny titel", task.getTitle());
assertEquals("Ny beskrivning", task.getDescription());
assertEquals(7, task.getPoints());
assertEquals(status, task.getStatus());
assertEquals(createdAt, task.getCreatedAt());
assertThrows(InvalidTaskException.class, () -> task.changeDetails("Titel", null, 0));
assertThrows(InvalidTaskException.class, () -> task.changeDetails("Titel", null, 100));
}
private Task taskWithPoints(int points) {
return new Task(
UUID.randomUUID(),
@ -22,6 +56,7 @@ class TaskTest {
null,
TaskStatus.WAITING,
points,
null,
Instant.parse("2026-07-26T12:00:00Z"));
}
}

View File

@ -23,12 +23,17 @@ byggprocess.
### Frontend
Frontend finns i `frontend/` och använder React 19, TypeScript, Vite och pnpm.
Den ansvarar för:
Frontend finns i `frontend/` och använder React 19, TypeScript, Vite, pnpm och
dnd-kit-ekosystemets aktuella React-adapter. 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;
- serverbekräftad redigering av uppgifters titel, beskrivning och poäng;
- 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;
- bekräftad och serverbekräftad permanent radering av uppgifter;
- klientnära validering och begripliga felmeddelanden;
- uppgiftsbrädan med kolumnerna Väntande, Pågående och Klart.
@ -60,6 +65,10 @@ Aktuella endpoints:
- `POST /api/users`
- `GET /api/tasks`
- `POST /api/tasks`
- `PUT /api/tasks/{taskId}/assignee`
- `PUT /api/tasks/{taskId}/status`
- `PUT /api/tasks/{taskId}/details`
- `DELETE /api/tasks/{taskId}`
### Databas och migreringar
@ -77,6 +86,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 +114,28 @@ 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.
Uppgifter raderas fysiskt genom task-repositoryt. Det finns ingen
mjukraderingsflagga, papperskorg eller återställningsmodell. Radering av en
uppgift påverkar inte dess ansvariga användare.
### Aktiv användare
@ -124,12 +150,25 @@ 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;
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.
Radering är serverbekräftad och använder samma låsning per task-id. Kortet och
bekräftelsedialogen ligger kvar tills backend svarar. Vid `204 No Content`
tas kortet bort lokalt. Ett `404`-svar tas endast som bekräftelse på att kortet
redan saknas när felkoden är `TASK_NOT_FOUND`; övriga fel behåller kortet och
dialogen för ett nytt försök.
### Teststrategi
@ -141,7 +180,9 @@ Backend har JUnit 5-tester:
Frontend använder Vitest, jsdom och React Testing Library. `fetch` och
`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

View File

@ -1,76 +0,0 @@
# Feature 0 Projektgrund
## Status
Färdig och mergad till `main`.
## Bakgrund
HemHub behövde en minimal projektgrund för inkrementell utveckling av en
webbapplikation med separat frontend och backend.
## Mål
Skapa körbara React- och Spring Boot-applikationer, koppla ihop dem lokalt och
etablera grundläggande tester och dokumentation.
## Omfattning
- monorepo med `backend/` och `frontend/`;
- Java 21, Spring Boot och Maven Wrapper;
- React, TypeScript, Vite och pnpm;
- health-endpoint och en tillfällig frontendstatus;
- Vite-proxy och grundtester;
- `README.md`, `AGENTS.md` och `.gitignore`.
## Avgränsningar
Feature 0 införde ingen databas, domänmodell, autentisering, deployment,
containerkonfiguration eller produktionskonfiguration.
## Beslut
Frontend och backend skapades som separata applikationer i samma repository.
Frontend använder relativa `/api`-adresser, och Vite proxar dem lokalt till
backend på port 8080. Ingen generell CORS-konfiguration infördes.
## Implementerad lösning
Backend skapades med Spring Boot 4.1.0, Java 21, Spring Web och Maven Wrapper.
Frontend skapades med React 19, TypeScript, Vite och pnpm.
Den ursprungliga startsidan anropade health-endpointen och visade backendstatus
eller ett anslutningsfel. Senare features har ersatt denna startsida, men
health-endpointen och dess test finns kvar.
## API-förändringar
`GET /api/health` infördes och returnerar:
```json
{"status":"UP"}
```
## Databasförändringar
Inga.
## Frontendförändringar
En minimal startsida visade rubriken HemHub, att frontend hade startat och
resultatet från `/api/health`. Vite konfigurerades att proxya `/api` till
`http://localhost:8080`.
## Tester och verifiering
Ett MockMvc-test verifierar status 200 och `status: UP`. Det ursprungliga
frontendtestet verifierade rubriken HemHub med mockat API-anrop.
## Kända begränsningar
Projektgrunden innehöll ingen användar- eller uppgiftsfunktionalitet. Den
ursprungliga health-vyn är inte längre appens aktiva vy.
## Relaterade commits
- `9957383e88b08dc006d2eaeb2a513c7769bd5705` `Initialize HemHub project foundation`

View File

@ -1,99 +0,0 @@
# Feature 1 Användarval
## Status
Färdig och mergad till `main`.
## Bakgrund
HemHub behövde centralt lagrade användare och ett enkelt sätt att välja vem som
använder applikationen, utan att införa autentisering.
## Mål
Göra det möjligt att lista och skapa användare, välja en aktiv användare,
återanvända valet i samma browser och lämna den aktiva vyn.
## Omfattning
- persistens, API och validering för användare;
- startflöden för tom och befintlig användarlista;
- lokalt lagrad aktiv användare;
- användarval, skapande och felhantering;
- automatiserade backend- och frontendtester.
## Avgränsningar
Ingen autentisering, lösenord, roll, behörighet, e-post, avatar,
hushållsrelation, redigering eller radering infördes.
## Beslut
Backend är slutlig auktoritet för namnvalidering. Namn normaliseras separat för
skiftlägesokänslig unikhet. Frontend lagrar endast UUID under
`hemhub.activeUserId` och verifierar det mot den hämtade användarlistan.
## Implementerad lösning
Vid appstart hämtar frontend alltid användarna. En tom lista leder direkt till
formuläret Skapa användare. Om användare finns men inget giltigt lokalt val
finns visas Vem är du?.
Val eller lyckat skapande sparar användarens id och aktiverar användaren. Ett
ogiltigt lagrat id rensas utan tekniskt fel. Feature 1:s tillfälliga startsida
och kontrollen Byt användare ersattes i Feature 2 av uppgiftsbrädan och
kontrollen Logga ut; lagringsmekanismen är oförändrad.
## API-förändringar
- `GET /api/users` returnerar alla användare.
- `POST /api/users` skapar en användare och returnerar `201 Created`.
API-responsen innehåller `id`, `name` och `createdAt`. `normalizedName` exponeras
inte.
Tomt namn eller namn längre än 50 Unicode-kodpunkter ger `400` med
`INVALID_USER_NAME`. Ett dubblettnamn utan hänsyn till stora och små bokstäver
ger `409` med `USER_NAME_ALREADY_EXISTS`.
## Databasförändringar
Flyway-migreringen `V1__create_users.sql` skapade tabellen `app_user`:
- UUID som primärnyckel;
- `name VARCHAR(50)`;
- unikt `normalized_name VARCHAR(150)`;
- `created_at TIMESTAMP WITH TIME ZONE`.
Lokalt används filbaserad H2 och i tester H2 in-memory. Backend genererar UUID
och `createdAt` med en UTC-klocka.
## Frontendförändringar
Frontend fick laddnings-, fel-, användarvals- och användarskapandevyer.
Skapandeformuläret trimmar namnet, gör en enkel längdkontroll, blockerar
dubbelsubmit och behåller inmatningen vid fel.
Nuvarande utloggning tar bort `hemhub.activeUserId`, rensar aktiv användare och
visar Vem är du? även om endast en användare finns.
## Tester och verifiering
Backendens integrationstester verifierar tom lista, skapande och listning,
trimning, ogiltiga namn, skiftlägesokänsliga dubbletter och alfabetisk
sortering.
Frontendtesterna verifierar tom lista, användarval, automatisk aktivering efter
skapande, bevarad inmatning vid fel, hämtfel med återförsök, ogiltigt lagrat id
och utloggning. API-anropen mockas.
## Kända begränsningar
Aktiv användare är ett lokalt gränssnittsval, inte säker autentisering. Valet
synkroniseras inte mellan browsers eller enheter. Användare kan inte redigeras
eller raderas.
## Relaterade commits
- `1ec7a729085d456d3185a1c74d02f99f40de0e8d` `feat: add user selection flow`
- `050f248857a01db2dc236a0ca35982fd70dab3d6` merge till `main`

View File

@ -1,108 +0,0 @@
# Feature 2 Skapa uppgifter
## Status
Färdig och mergad till `main`.
## Bakgrund
Efter införandet av aktiv användare behövde HemHub en första gemensam
uppgiftsmodell och en enkel bräda för att skapa och visa uppgifter.
## Mål
Låta en aktiv användare se tre statuskolumner, skapa en uppgift med titel och
valfri beskrivning samt se den sparade uppgiften efter omladdning.
## Omfattning
- persistent uppgiftsmodell och Flyway-migrering;
- API för att skapa och lista uppgifter;
- bräda med Väntande, Pågående och Klart;
- modal för att skapa uppgifter;
- laddnings-, validerings- och felhantering;
- automatiserade backend- och frontendtester.
## Avgränsningar
Ingen ändring av status, drag-and-drop, tilldelning, användarrelation, poäng,
deadline, återkommande uppgift, redigering, radering, sökning, filtrering eller
paginering infördes.
## Beslut
Alla användare ser samma uppgifter; uppgiftsmodellen har ingen relation till en
användare. Backend väljer alltid status `WAITING` vid skapande. Listningen
sorteras i backend efter `createdAt ASC, id ASC`, och frontend bevarar den
ordningen.
## Implementerad lösning
JPA-entiteten `Task` innehåller UUID, titel, valfri beskrivning, status och
skapandetid. Backend genererar UUID och `createdAt` med en UTC-klocka.
Frontend visar uppgiftsbrädan när ett giltigt aktivt användarval finns. Uppgifter
hämtas vid montering, grupperas efter status och visas med endast titel och
eventuell beskrivning. Tomma kolumner saknar tomlägestext.
## API-förändringar
- `GET /api/tasks` returnerar samtliga uppgifter, äldst först och med UUID som
sekundär sorteringsnyckel.
- `POST /api/tasks` skapar en uppgift och returnerar `201 Created`.
Titel trimmas, är obligatorisk och får omfatta högst 100 Unicode-kodpunkter.
Beskrivning trimmas, får omfatta högst 500 Unicode-kodpunkter och lagras som
`null` om den är tom. Ogiltiga anrop ger `400` med felkoden `INVALID_TASK`.
## Databasförändringar
Flyway-migreringen `V2__create_tasks.sql` skapade tabellen `task`:
- `id UUID PRIMARY KEY`;
- `title VARCHAR(100) NOT NULL`;
- `description VARCHAR(500)`;
- `status VARCHAR(20) NOT NULL`;
- `created_at TIMESTAMP WITH TIME ZONE NOT NULL`.
Status lagras som text genom `@Enumerated(EnumType.STRING)`. Databasen har ingen
check constraint för enumvärden.
## Frontendförändringar
Feature 1:s tillfälliga aktiva vy ersattes med uppgiftsbrädan. Sidhuvudet visar
aktiv användares namn, Logga ut och Ny uppgift.
Skapandemodalen innehåller titel och valfri beskrivning. Titelfältet får fokus
när modalen öppnas. När inget submit-anrop pågår kan den stängas med kryss,
Escape eller klick på bakgrunden. Normal stängning avmonterar komponenten och
nollställer därmed formuläret.
Vid submit gör frontend samma grundläggande längdkontroller, skickar trimmade
värden och blockerar uppenbara dubbelsubmit. Vid fel stannar modalen öppen med
bevarad inmatning. Vid framgång läggs API-svaret sist i den befintliga listan,
vilket placerar den nya `WAITING`-uppgiften längst ned i Väntande utan att
sortera om backendens ordning.
## Tester och verifiering
Backendens integrationstester verifierar skapande, `WAITING`, trimning, tom
beskrivning som `null`, längdvalidering samt sorteringen `createdAt ASC, id ASC`.
Frontendtesterna verifierar bräda och statusgruppering, tomma kolumner,
modalöppning och fokus, skapande, ordning efter skapande, bevarad formulärdata
vid API-fel samt utloggning. Parametriserade testfall verifierar också stängning
med kryss, Escape och bakgrundsklick samt att formuläret är rensat när modalen
öppnas igen.
## Kända begränsningar
Statusvärden utöver `WAITING` kan visas om de redan finns i databasen, men inget
nuvarande API eller gränssnitt kan flytta en uppgift mellan kolumnerna. Det
finns ingen koppling mellan uppgifter och skapande eller aktiv användare.
Modalen har ingen fokusfälla eller explicit fokusåterställning.
## Relaterade commits
- `3f152eecccdd88f840066543bf9321b81b4cead8` `feat: add task creation board`
- `2f7b99fb21c57c2e9c5f019a2b5073458e41c939` merge till `main`

View File

@ -1,438 +0,0 @@
# Feature 3 Uppgiftspoäng
## Status
Pågående.
## Bakgrund
HemHub ska på sikt kunna använda spelifiering för att uppmuntra
familjemedlemmar att utföra uppgifter. Exempel på framtida funktioner kan vara
mål, achievements och belöningar baserade på hur många poäng en användare
samlar under en viss period.
Feature 3 inför den grundläggande poänginformationen på uppgiften. Funktionen
registrerar endast uppgiftens poängvärde. Intjäning av poäng och övrig
spelifiering införs i senare features.
## Mål
Feature 3 ska:
- lägga till ett obligatoriskt poängvärde på varje uppgift;
- låta användaren ange poäng när en uppgift skapas;
- visa poängen på uppgiftskortet;
- validera poängen konsekvent i frontend och backend;
- dokumentera hur lokal utvecklingsdata hanteras.
## Betydelsen av poäng
Poängen uttrycker uppgiftens samlade värde utifrån hur:
- tidskrävande uppgiften är;
- besvärlig uppgiften är;
- viktig uppgiften är.
När poängintjäning införs i en senare feature ska samma värde motsvara hur många
poäng användaren får när uppgiften slutförs.
Poängen är inte en exakt tidsuppskattning. En snabb men viktig uppgift kan
därför ha ett högre poängvärde än en längre men mindre betydelsefull uppgift.
Feature 3 registrerar endast poängvärdet. Den ska inte registrera:
- vem som har tjänat poängen;
- om poängen har delats ut;
- när poängen har tjänats in;
- någon historik över poäng.
## Poängskala
Poäng ska vara ett heltal mellan 1 och 99, inklusive gränsvärdena.
Alla heltal i intervallet är tillåtna. Feature 3 inför inte någon fast skala med
fördefinierade steg.
Giltiga exempel:
- 1
- 7
- 25
- 99
Ogiltiga exempel:
- inget värde;
- `null`;
- 0;
- negativa tal;
- 100 eller högre;
- decimaltal;
- text som inte kan tolkas som ett heltal.
En fast poängskala kan införas senare om erfarenhet från användningen visar att
det är lämpligt.
## Avgränsning
Feature 3 omfattar endast:
- uppgiftens titel;
- uppgiftens valfria beskrivning;
- uppgiftens obligatoriska poängvärde;
- visning av poäng på uppgiftskortet.
Feature 3 ska inte införa:
- tilldelning av uppgifter;
- ändring av uppgiftsstatus;
- drag-and-drop;
- redigering av befintliga uppgifter;
- radering av uppgifter;
- deadlines;
- återkommande uppgifter;
- poänghistorik;
- användares poängsaldo;
- topplistor;
- statistik;
- mål;
- achievements;
- belöningar;
- automatisk utdelning av poäng när en uppgift slutförs.
Dessa funktioner hanteras i senare features enligt roadmapen.
## Användarflöde
När användaren öppnar dialogen för att skapa en uppgift ska formuläret
innehålla:
- titel;
- beskrivning;
- poäng.
Poängfältet ska initialt innehålla värdet `1`.
Användaren kan behålla standardvärdet eller ange ett annat heltal mellan 1 och
99.
När uppgiften skapas ska frontend alltid skicka poängvärdet uttryckligen till
backend. Backend ska inte själv fylla i ett saknat värde.
Efter att en uppgift har skapats framgångsrikt ska formuläret återställas.
Poängfältet ska då återgå till `1`.
Om dialogen stängs och senare öppnas igen ska poängfältet också börja på `1`.
## Skapandedialog
Poäng ska anges med ett vanligt numeriskt inmatningsfält.
Fältet ska ha:
- etiketten `Poäng`;
- initialt värde `1`;
- minsta värde `1`;
- högsta värde `99`;
- heltalssteg.
En kort hjälptext kan visas:
> 199 poäng beroende på hur tidskrävande, besvärlig eller viktig uppgiften är.
Fältet får tillfälligt vara tomt medan användaren redigerar värdet. Frontend ska
inte automatiskt återställa värdet till `1` medan användaren skriver.
Validering ska främst ske när användaren försöker skicka formuläret. Avancerad
validering vid varje tangenttryckning ingår inte i denna feature.
## Frontendvalidering
Frontend ska blockera skapandeanropet om poängen inte är ett heltal mellan 1 och
99.
Vid ett ogiltigt värde ska följande meddelande visas:
> Poäng måste vara ett heltal mellan 1 och 99.
Samma meddelande kan användas för:
- tomt värde;
- värde under 1;
- värde över 99;
- decimaltal;
- annat ogiltigt innehåll.
HTML-fältets attribut för minsta värde, högsta värde och heltalssteg får användas
som stöd, men formulärlogiken ska också kontrollera värdet explicit.
Backend är alltid den slutliga garanten för valideringsreglerna.
## Visning på uppgiftskortet
Uppgiftens poäng ska visas på uppgiftskortet som en kompakt och dynamisk badge.
Badgen ska:
- renderas som en vanlig React- och HTML-komponent;
- använda text och CSS;
- läsa värdet från uppgiftens `points`;
- visa värdet i formatet `{points} p`.
Exempel:
- `1 p`
- `7 p`
- `99 p`
Ingen genererad bild eller statisk grafik ska användas för själva poängvärdet.
Placering och visuell utformning ska följa projektets befintliga skärmbilder och
nuvarande kortdesign. Poängindikatorn ska ligga i kortets metadataområde på
motsvarande plats som poängindikatorn i designreferensen.
Mindre justeringar får göras för att passa den faktiska kortimplementationen.
Feature 3 ska däremot inte införa en ny övergripande design för uppgiftskortet.
## API
Fältnamnet ska vara `points` genomgående i API, backend och frontend.
### Skapa uppgift
Requesten för att skapa en uppgift ska innehålla:
```json
{
"title": "Töm diskmaskinen",
"description": "Ställ in allt i rätt skåp",
"points": 3
}
```
`points` är obligatoriskt.
Backend ska inte tolka ett saknat värde som `1`.
### Uppgiftssvar
API-svar som innehåller en uppgift ska också innehålla `points`.
Exempel:
```json
{
"id": "00000000-0000-0000-0000-000000000000",
"title": "Töm diskmaskinen",
"description": "Ställ in allt i rätt skåp",
"status": "WAITING",
"points": 3,
"createdAt": "2026-07-26T12:00:00Z"
}
```
Det gäller både:
- svaret efter att en uppgift skapats;
- listning av uppgifter.
Det exakta API-formatet ska i övrigt följa den befintliga implementationen.
## Backendregler
En uppgift får aldrig existera med ett poängvärde utanför intervallet 199.
Regeln ska skyddas genom hela backend, inte bara i HTTP-lagret.
Beroende på repositoryts befintliga struktur ska valideringen tillämpas på
relevanta nivåer, exempelvis:
- requestvalidering;
- applikations- eller domänlogik;
- entitetsmodell;
- databasens schema.
Implementation ska följa projektets etablerade kodstruktur och inte introducera
ett nytt arkitekturmönster enbart för denna feature.
## Felhantering
Ett ogiltigt eller saknat `points` ska ge:
```text
400 Bad Request
```
Backend ska använda projektets befintliga felformat och befintliga
felhantering.
Feature 3 ska inte introducera en separat felmodell endast för poäng.
Backend får ge mer precisa valideringsdetaljer för exempelvis:
- saknat värde;
- `null`;
- värde under 1;
- värde över 99.
Frontend behöver inte återge varje backenddetalj separat, utan kan visa det
gemensamma användarmeddelandet:
> Poäng måste vara ett heltal mellan 1 och 99.
Vid andra eller oväntade backendfel ska frontend fortsätta använda projektets
befintliga generella felhantering.
## Databas
Databasschemat ska innehålla ett obligatoriskt heltalsfält för uppgiftens poäng.
Det logiska slutläget är:
```text
points INTEGER NOT NULL
```
Databasen ska, om den befintliga schemahanteringen stödjer det, även skydda
intervallet 199 med en motsvarande constraint.
Databasen ska inte ha ett permanent defaultvärde för nya uppgifter. Nya
uppgifter ska alltid få ett uttryckligt poängvärde från applikationen.
Det förvalda värdet `1` är ett frontendbeteende och inte ett sätt för backend
eller databasen att tyst komplettera ofullständiga anrop.
## Lokal utvecklingsdatabas
Den lokala utvecklingsdatabasen ska vara en in-memory H2-databas.
Databasen och dess innehåll ska återställas när backend startas om.
Lokal utvecklingsdata betraktas därför som tillfällig. Användare och uppgifter
som skapats manuellt under utveckling behöver inte bevaras mellan starter.
Detta innebär att Feature 3 inte behöver migrera verkliga befintliga
utvecklingsposter. En ny databas skapas direkt med det obligatoriska
poängfältet.
Före Feature 3 var lokal H2 filbaserad. Feature 3 ändrar utvecklingsanslutningen
till in-memory och uppdaterar utvecklingsdokumentationen i samma ändring.
## Schemahantering och framtida migrering
Att lokal utvecklingsdata inte bevaras innebär inte att framtida
produktionsdata kan återställas vid varje release.
När HemHub börjar använda en beständig PostgreSQL-databas med data som ska
bevaras måste schemaändringar hanteras med kontrollerade migreringar.
Feature 3 behöver inte införa eller färdigställa hela den framtida
produktionsstrategin om den ännu inte finns i repositoryt.
Projektet använder redan Flyway och versionshanterade migreringar. Feature 3 ska
därför lägga till en ny Flyway-migrering för poängfältet och inte ändra tidigare
migreringar. Hibernate ska fortsatt validera schemat i stället för att skapa
det.
Bytet till in-memory H2 innebär att befintliga lokala utvecklingsposter inte
behöver bevaras eller fyllas på med poäng. Själva schemaändringen ska ändå
hanteras som en kontrollerad migrering så att migrationshistoriken förblir
sammanhängande inför framtida beständig data.
Repositoryts faktiska arkitektur och dokumentation har företräde.
## Backendtester
Feature 3 ska minst verifiera att:
- en uppgift kan skapas med ett giltigt `points`;
- det skapade API-svaret innehåller samma `points`;
- listning av uppgifter innehåller `points`;
- gränsvärdet `1` accepteras;
- gränsvärdet `99` accepteras;
- saknat `points` ger `400 Bad Request`;
- `points: null` ger `400 Bad Request`;
- `points: 0` ger `400 Bad Request`;
- 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.
## Frontendtester
Feature 3 ska minst verifiera att:
- skapandedialogen öppnas med poängvärdet `1`;
- ett giltigt poängvärde skickas i create-anropet;
- tomt poängfält blockerar submit;
- ett värde under 1 blockerar submit;
- ett värde över 99 blockerar submit;
- ett ogiltigt värde visar felmeddelandet;
- formuläret återställs till poängvärdet `1` efter lyckad skapning;
- 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.
## Manuell verifiering
Följande ska verifieras manuellt:
1. Starta frontend och backend enligt projektets utvecklingsinstruktioner.
2. Skapa en uppgift utan att ändra poängfältet.
3. Verifiera att uppgiften får `1 p`.
4. Skapa en uppgift med ett mellanvärde, exempelvis `7`.
5. Verifiera att uppgiften får `7 p`.
6. Skapa en uppgift med `99`.
7. Verifiera att uppgiften får `99 p`.
8. Försök skapa en uppgift med tomt poängfält.
9. Verifiera att anropet blockeras och att rätt felmeddelande visas.
10. Försök använda värdena `0` och `100`.
11. Verifiera att båda avvisas.
12. Kontrollera att poängbadgen följer projektets designreferens och fungerar
med ett- och tvåsiffriga värden.
13. Starta om backend.
14. Verifiera att den lokala utvecklingsdatan inte finns kvar.
## Acceptanskriterier
Feature 3 är klar när:
- varje ny uppgift har ett obligatoriskt `points`;
- `points` är ett heltal mellan 1 och 99;
- frontendens standardvärde är `1`;
- frontend alltid skickar `points` uttryckligen;
- backend avvisar saknat eller ogiltigt `points`;
- backend fyller inte automatiskt i ett saknat värde;
- uppgiftens poäng returneras av API:t;
- uppgiftens poäng visas dynamiskt på uppgiftskortet;
- frontend- och backendtester täcker centrala giltiga och ogiltiga fall;
- lokal H2 körs som in-memory och återställs vid omstart;
- relevant dokumentation är uppdaterad;
- Feature 3 inte inför funktionalitet som hör till senare features.
## Implementationsprinciper
När Feature 3 senare implementeras ska Codex först läsa:
```text
AGENTS.md
README.md
docs/architecture.md
docs/development.md
docs/roadmap.md
docs/decisions/
docs/features/
```
Codex ska även läsa relevant backendkod, frontendkod och befintliga tester innan
ändringar görs.
Repositoryts faktiska kod och dokumentation har företräde framför antaganden i
denna featurebeskrivning.
Dokumentation, implementation och tester ska uppdateras tillsammans.
Codex ska inte committa, pusha, skapa pull request eller merga utan uttrycklig
instruktion.

View File

@ -0,0 +1,470 @@
# HemHub Sammanfattning av färdiga features
Detta dokument sammanfattar de features som är färdiga, verifierade och mergade till `main`.
Syftet är att ge ChatGPT, Codex och andra arbetsdialoger en kompakt överblick över projektets historik utan att samtliga fullständiga featuredokument behöver finnas som aktiva projektkällor.
De fullständiga och bindande historiska dokumenten finns fortsatt under:
```text
docs/features/
```
Repositoryts aktuella kod, tester, `docs/architecture.md`, `docs/roadmap.md`, relevanta ADR:er och respektive fullständiga featuredokument har alltid företräde framför denna sammanfattning.
## Aktuellt läge
Feature 08 är färdiga, verifierade och mergade till `main`.
Nästa planerade produktfeature är:
```text
Feature 9 Deadline
```
Den aktuella applikationen har:
- React/Vite-frontend och Spring Boot-backend i ett monorepo;
- centralt lagrade användare och ett lokalt browserval av aktiv användare;
- en gemensam Kanban-bräda med Väntande, Pågående och Klart;
- uppgifter med titel, valfri beskrivning, poäng, status och valfri ansvarig;
- skapande, tilldelning, statusändring, drag-and-drop, permanent radering och redigering;
- låsning och felhantering per task-id;
- serverbekräftade uppdateringar för tilldelning, statusknappar, radering och redigering;
- optimistisk drag-and-drop med full rollback.
Aktiv användare är ett lokalt gränssnittsval och inte autentisering eller behörighetskontroll.
---
## Feature 0 Projektgrund
**Status:** Färdig och mergad till `main`.
### Resultat
- Monorepo med `backend/`, `frontend/` och `docs/`.
- Backend med Java 21, Spring Boot, Maven Wrapper och Spring Web.
- Frontend med React, TypeScript, Vite och pnpm.
- Health-endpoint: `GET /api/health`.
- Vite-proxy från `/api` till lokal backend på port 8080.
- Grundläggande backend- och frontendtester.
- Inledande `README.md`, `AGENTS.md` och `.gitignore`.
### Viktiga beslut
- Frontend och backend är separata applikationer i samma repository.
- Frontend använder relativa `/api`-adresser.
- Ingen generell CORS-konfiguration infördes.
### Begränsningar
Ingen databas, domänmodell, autentisering, deployment eller containerkonfiguration infördes.
### Relaterade commits
- Feature-commit: `9957383e88b08dc006d2eaeb2a513c7769bd5705`
Fullständig historik: `docs/features/000-project-foundation.md`.
---
## Feature 1 Användarval
**Status:** Färdig och mergad till `main`.
### Resultat
- Centralt lagrade användare.
- API: `GET /api/users` och `POST /api/users`.
- UUID som användar-id.
- Namn trimmas och valideras.
- Skiftlägesokänslig unikhet genom internt normaliserat namn.
- Frontendflöden för användarval, skapande, laddning, fel och utloggning.
- Aktiv användares UUID lagras under `hemhub.activeUserId`.
### Viktiga beslut
- Backend är slutlig auktoritet för namnvalidering.
- Endast användar-id lagras lokalt.
- Id:t verifieras mot backendens användarlista vid appstart.
- Ogiltigt lagrat id tas bort.
- `Logga ut` rensar valet och visar användarvalet igen.
- Lösningen är inte autentisering.
### Databas
Flyway-migreringen `V1__create_users.sql` skapade `app_user`.
### Relaterade commits
- Feature-commit: `1ec7a729085d456d3185a1c74d02f99f40de0e8d`
- Merge-commit: `050f248857a01db2dc236a0ca35982fd70dab3d6`
Fullständig historik: `docs/features/001-user-selection.md`.
---
## Feature 2 Skapa uppgifter
**Status:** Färdig och mergad till `main`.
### Resultat
- Persistent uppgiftsmodell.
- API: `GET /api/tasks` och `POST /api/tasks`.
- Kanban-bräda med `WAITING`, `IN_PROGRESS` och `COMPLETED`.
- Modal för att skapa uppgifter.
- Ursprungliga fält: UUID, titel, valfri beskrivning, status och skapandetid.
- Nya uppgifter får alltid status `WAITING`.
- Listningen sorteras efter `createdAt ASC, id ASC`.
### Validering
- Titel är obligatorisk, trimmas och får vara högst 100 Unicode-kodpunkter.
- Beskrivning är valfri, trimmas, får vara högst 500 Unicode-kodpunkter och lagras som `null` när den är tom.
### Frontendbeteende
- Titelfältet får fokus när modalen öppnas.
- Modalen kan före submit stängas med kryss, Escape eller bakgrundsklick.
- Dubbelsubmit blockeras.
- Inmatning behålls vid API-fel.
- Serverresponsen läggs sist i befintlig lista.
### Databas
Flyway-migreringen `V2__create_tasks.sql` skapade `task`.
### Relaterade commits
- Feature-commit: `3f152eecccdd88f840066543bf9321b81b4cead8`
- Merge-commit: `2f7b99fb21c57c2e9c5f019a2b5073458e41c939`
Fullständig historik: `docs/features/002-task-creation.md`.
---
## Feature 3 Uppgiftspoäng
**Status:** Färdig och mergad till `main`.
### Resultat
- Obligatoriskt fält `points` på varje uppgift.
- Tillåtna värden är heltal 199.
- Skapandemodalen använder standardvärdet `1`.
- Frontend skickar alltid poäng uttryckligen.
- Backend fyller inte automatiskt i saknade poäng.
- Uppgiftskort visar `{points} p`.
### Databas
- Flyway-migreringen `V3__add_task_points.sql`.
- `points INTEGER NOT NULL`.
- Constraint för intervallet 199.
- Inget permanent databasdefaultvärde.
### Viktiga beslut
Poäng uttrycker uppgiftens samlade värde utifrån hur tidskrävande, besvärlig eller viktig den är.
Ingen poänghistorik, summering eller utdelning infördes.
Lokal utveckling ändrades till H2 in-memory och utvecklingsdata återställs vid omstart.
### Relaterade commits
- Feature-commit: `059d4da9214969ed3e28592178160da6de614b4d`
- Merge-commit: `2e62261f49bb3142e28882483e41e0250ab11c5f`
Fullständig historik: `docs/features/003-task-points.md`.
---
## Feature 4 Tilldelning av uppgifter
**Status:** Färdig och mergad till `main`.
### Resultat
- En uppgift kan vara otilldelad eller ha exakt en ansvarig.
- Valfri ansvarig vid skapande.
- API: `PUT /api/tasks/{taskId}/assignee`.
- Task-responsen innehåller `assignee: null` eller `{id, name}`.
- Frontend visar och ändrar ansvarig på kortet.
- Serverresponsen ersätter den lokala uppgiften utan omsortering.
### Viktiga beslut
- Högst en ansvarig.
- Ingen förvald ansvarig vid skapande.
- Aktiv användare är inte behörighetsgrund.
- Tilldelning ändrar inte status.
Feature 5 utvidgade senare de statusberoende tilldelningsreglerna.
### Databas
`V4__add_task_assignee.sql` lade till nullable `assignee_id` med främmande nyckel till `app_user`.
JPA-relationen är lazy `ManyToOne`, och entity graph används för att hämta ansvarig med uppgiften.
### Relaterade commits
- Feature-commit: `aaebe888f3bae43b3413e9fa3893fec90afac8b4`
- Merge-commit: `d78611f5f77374228f1f69b66734931a988f8355`
Fullständig historik: `docs/features/004-task-assignment.md`.
---
## Feature 5 Statusändring och statusregler
**Status:** Färdig och mergad till `main`.
### Resultat
- API: `PUT /api/tasks/{taskId}/status`.
- Alla direkta övergångar mellan `WAITING`, `IN_PROGRESS` och `COMPLETED`.
- Samma målstatus är giltig och idempotent.
- Knappbaserat statusflöde på korten.
- Serverbekräftad uppdatering och låsning per task-id.
### Status- och tilldelningsregler
- `IN_PROGRESS` måste ha ansvarig.
- `WAITING` och `COMPLETED` får vara otilldelade.
- Tilldelnings-API:t får tilldela eller byta ansvarig i alla statusar.
- Ansvarig får tas bort i `WAITING` och `COMPLETED`, men inte i `IN_PROGRESS`.
- Tilldelning ändrar aldrig status.
- När en otilldelad uppgift påbörjas skickar frontend aktiv användares id.
- Backend tilldelar användaren och ändrar status i samma transaktion.
- Befintlig ansvarig byts aldrig av statusoperationen.
### Centrala felkoder
- `TASK_NOT_FOUND`
- `INVALID_TASK_STATUS`
- `USER_NOT_FOUND`
- `TASK_REQUIRES_ASSIGNEE`
### Relaterade commits
- Feature-commit: `65a6488c0b268f49b1361591f025bdea8d67f754`
Fullständig historik: `docs/features/005-task-status.md`.
---
## Feature 6 Drag-and-drop
**Status:** Färdig och mergad till `main`.
### Resultat
- Drag-and-drop mellan statuskolumner.
- Återanvänder Feature 5:s status-API.
- Frontend använder `@dnd-kit/react` och `@dnd-kit/dom`.
- Ingen sortering inom kolumner och ingen persistent kortordning.
- Dragbiblioteket är avgränsat i `TaskDragAndDrop.tsx`.
### Uppdateringsstrategi
Drag-and-drop är optimistiskt:
1. hela tidigare task-versionen sparas;
2. kortet flyttas direkt;
3. kortet låses och tonas ned;
4. statusanropet skickas;
5. serverresponsen ersätter det optimistiska värdet;
6. vid fel återställs hela tidigare task-versionen.
Rollback omfattar hela uppgiften eftersom flytt till `IN_PROGRESS` även kan innebära optimistisk tilldelning till aktiv användare.
Statusknapparna förblev serverbekräftade.
### Interaktion
- Drop i samma kolumn är no-op.
- Avbruten dragning eller ogiltigt mål är no-op.
- Kortets icke-interaktiva yta är dragyta.
- Knappar och andra interaktiva kontroller startar inte drag.
- Låsning sker per task-id.
- Andra kort förblir interaktiva.
### Relaterade commits
- Feature-commit: `c3c64482c062f144fd6cb6036e3c9db0afa5ec1e`
- Merge-commit: `2696195e741c155a197ae9838d1938bdf2148cc2`
Fullständig historik: `docs/features/006-task-drag-and-drop.md`.
---
## Feature 7 Radera uppgift
**Status:** Färdig och mergad till `main`.
### Resultat
- Permanent fysisk radering.
- API: `DELETE /api/tasks/{taskId}`.
- Lyckad radering ger `204 No Content`.
- Okänd eller redan raderad uppgift ger `404 TASK_NOT_FOUND`.
- Uppgifter får raderas i alla statusar.
- Aktiv användare och ansvarig påverkar inte möjligheten att radera.
### Frontendflöde
- Diskret inline-SVG-sopkorg i kortets åtgärdsområde.
- Separat bekräftelsemodal.
- Radering är serverbekräftad.
- Kort och modal ligger kvar under anropet.
- Samtliga stängningsvägar blockeras under anropet.
- Kortet tas bort först efter `204`.
- Vanliga fel behåller kort och dialog.
- Strukturerat `404 TASK_NOT_FOUND` tar bort ett inaktuellt lokalt kort.
### Viktiga beslut
- Ingen mjuk radering.
- Ingen papperskorg, återställning, undo eller arkivering.
- Ingen Flyway-migrering behövdes.
- Samma låsning per task-id används för status, tilldelning, drag och radering.
### Relaterade commits
- Feature-commit: `f296d15`
- Merge-commit: `5df0146`
Fullständig historik: `docs/features/007-task-deletion.md`.
---
## Feature 8 Redigera uppgift
**Status:** Färdig och mergad till `main`.
### Resultat
- Redigering av titel, beskrivning och poäng.
- Separat redigeringsmodal.
- Synlig inline-SVG-redigeringsknapp bredvid sopkorgen.
- API: `PUT /api/tasks/{taskId}/details`.
- Requesten innehåller alltid hela den redigerbara fältuppsättningen.
- Lyckad uppdatering ger `200 OK` med fullständig task-respons.
### Avgränsning
Redigeringsflödet ändrar inte id, status, ansvarig eller skapandetid.
Status och ansvarig hanteras fortsatt genom sina specialiserade API:er.
### Frontendflöde
- Aktuella värden fylls i när modalen öppnas.
- `null`-beskrivning visas som tom sträng.
- Titelfältet får initialt fokus.
- Osparade ändringar kastas utan extra bekräftelse.
- Redigeringen är serverbekräftad.
- Gamla kortvärden ligger kvar under anropet.
- Modalen och kortet låses under save.
- Serverresponsen ersätter uppgiften på befintlig plats.
- Kolumn och ordning behålls.
- Vanliga fel behåller modal och inmatning.
- Strukturerat `404 TASK_NOT_FOUND` tar bort ett inaktuellt lokalt kort.
### Backendregler
- Samma validering som vid skapande.
- Samma värden accepteras idempotent.
- Redigering tillåts i alla statusar.
- Ingen `updatedAt`.
- Ingen Flyway-migrering behövdes.
### Verifierad begränsning
H2:s `VARCHAR` räknar UTF-16-kodenheter för vissa tecken utanför BMP. Applikationen validerar enligt Unicode-kodpunkter, men H2 kan därför avvisa vissa gränsfall med astrala tecken. Detta ska verifieras mot PostgreSQL när produktionsdatabasen införs.
### Relaterade commits
- Feature-commit: `443f686`
- Merge-commit: `28258f4`
Fullständig historik: `docs/features/008-task-editing.md`.
---
## Tvärgående beslut efter Feature 8
### API och backend
- Backend är slutlig garant för affärsregler och validering.
- Task-operationer är avgränsade till skapande, detaljredigering, tilldelning, statusändring och radering.
- Serverns fullständiga task-respons är slutlig sanning.
- Kända fel använder gemensamt format med `code` och `message`.
### Frontend-state
- React-komponenter hanterar lokalt state.
- Ingen router eller separat global state-lösning används.
- Operationer låses per task-id.
- Andra kort förblir interaktiva när ett kort har ett pågående anrop.
- Statusknappar, tilldelning, radering och redigering är serverbekräftade.
- Drag-and-drop är optimistiskt med full rollback.
### Databas
Aktuella Flyway-migreringar:
```text
V1__create_users.sql
V2__create_tasks.sql
V3__add_task_points.sql
V4__add_task_assignee.sql
```
Lokal utveckling och automatiska tester använder H2 in-memory i PostgreSQL-kompatibilitetsläge.
PostgreSQL är planerad produktionsdatabas men ännu inte implementerad eller verifierad.
### Deployment
Planerad men ännu inte implementerad riktning:
- PostgreSQL;
- separata Docker-images för frontend och backend;
- Drone för bygge och publicering;
- privat registry;
- Watchtower för uppdatering;
- Nginx som möjlig reverse proxy;
- drift på Ubuntu-servern Biff.
### Utvecklingsprocess
- En kortlivad branch per feature.
- Branch skapas från uppdaterad `main`.
- Kod, tester och relevant dokumentation uppdateras tillsammans.
- Commit och push sker först efter uttrycklig instruktion.
- Merge sker efter automatisk och relevant manuell verifiering.
- Dokumentationen ska räcka för att förstå projektet utan tidigare dialoger eller raderade branches.
## Nästa feature
```text
Feature 9 Deadline
```
Roadmapens nuvarande mål:
- valfri deadline;
- beslutad representation av datum och eventuell tid;
- visning på uppgiftskort;
- markering av försenade uppgifter.
Öppna frågor:
- datum utan tid eller datum och tid;
- tidszonshantering;
- definition av försenad uppgift.

View File

@ -34,20 +34,29 @@ Följande statusvärden används:
## Nuvarande läge
Feature 02 är klara. Den aktuella applikationen har:
Feature 08 är klara och finns på `main`. 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 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;
- serverbekräftad permanent radering med bekräftelsedialog;
- serverbekräftad redigering av titel, beskrivning och poäng;
- 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.
Nuvarande användarval är inte autentisering.
Tilldelning och status är separata egenskaper; tilldelningsflödet ändrar inte
uppgiftens status. Alla direkta statusövergångar är tillåtna och
`IN_PROGRESS` kräver ansvarig. Det finns ännu ingen deadline eller återkommande
uppgift. Nuvarande användarval är inte autentisering.
**Feature 3 Uppgiftspoäng är pågående.**
**Feature 9 Deadline är nästa planerade produktfeature.**
## Featureöversikt
@ -56,12 +65,12 @@ 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 |
| 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 |
| 3 Uppgiftspoäng | Klar | 2 | Poäng på uppgifter |
| 4 Tilldelning | Klar | 12 | Valfri ansvarig användare |
| 5 Statusändring | Klar | 4 | Backendstyrda statusövergångar |
| 6 Drag-and-drop | Klar | 5 | Kortflytt via status-API |
| 7 Radera uppgift | Klar | 2 | Bekräftad permanent radering |
| 8 Redigera uppgift | Klar | 3 | Titel, beskrivning och poäng |
| 9 Deadline | Planerad | 2 | Valfri deadline och förseningsmarkering |
| 10 Sökning och filtrering | Planerad | 2; 4 för ansvarig; 9 för deadline | Sökning och filter på brädan |
| 11 Design av återkommande uppgifter | Planerad | 35, 9 | Beslut och plan, ingen produktionskod |
@ -109,7 +118,7 @@ interaktiv brädhantering införs.
### Feature 3 Uppgiftspoäng
**Status:** Pågående
**Status:** Klar
**Beroenden:** Feature 2
@ -130,7 +139,7 @@ databasen har inget permanent defaultvärde.
### Feature 4 Tilldelning av uppgifter
**Status:** Planerad
**Status:** Klar
**Beroenden:** Feature 1 och Feature 2
@ -141,17 +150,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:** Klar
**Beroenden:** Feature 4
@ -164,17 +170,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
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
**Status:** Planerad
**Status:** Klar
**Beroenden:** Feature 5
@ -188,10 +194,13 @@ bygga en komplex interaktion.
Drag-and-drop kommer efter det enklare statusflödet för att återanvända
verifierade backendregler.
**Öppna frågor:**
- optimistisk eller serverbekräftad uppdatering;
- exakt tilldelningsflöde vid flytt till Pågående.
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
använder Feature 5:s befintliga automatiska tilldelning till aktiv användare.
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 genomförda, och featuren är mergad till
`main`.
## Fas 2 Hantering av uppgifter
@ -200,7 +209,7 @@ modellen och statusreglerna finns.
### Feature 7 Radera uppgift
**Status:** Planerad
**Status:** Klar
**Beroenden:** Feature 2
@ -213,13 +222,17 @@ modellen och statusreglerna finns.
Radering hålls separat från redigering så att databorttagning och dess
konsekvenser kan verifieras isolerat.
**Öppen fråga:**
- permanent radering eller mjuk radering.
Feature 7 använder permanent fysisk radering genom
`DELETE /api/tasks/{taskId}`. En bekräftelsemodal visas före anropet och
frontend behåller kortet tills backend har bekräftat raderingen. Operationen
använder samma låsning per task-id som status, tilldelning och drag-and-drop.
Ett `404 TASK_NOT_FOUND` tar bort ett känt inaktuellt lokalt kort.
Implementation samt automatisk och manuell verifiering är genomförda, och
featuren är mergad till `main`.
### Feature 8 Redigera uppgift
**Status:** Planerad
**Status:** Klar
**Beroenden:** Feature 3
@ -233,6 +246,12 @@ Featuren ligger efter poäng för att redigeringsflödet ska omfatta den då
aktuella uppgiftsmodellen. Ansvarig ska fortsatt ändras genom
tilldelningsflödet från Feature 4 och status genom statusflödet från Feature 5.
Den implementerade lösningen använder `PUT /api/tasks/{taskId}/details` och
uppdaterar endast titel, beskrivning och poäng. Frontend använder en separat
redigeringsmodal och serverbekräftad uppdatering genom den gemensamma låsningen
per task-id. Implementation, automatiska kontroller och manuell
browserverifiering är genomförda, och featuren är mergad till `main`.
### Feature 9 Deadline
**Status:** Planerad
@ -421,7 +440,6 @@ Nuvarande aktiva användarval är uttryckligen inte autentisering.
## Öppna tvärgående frågor
- Ska uppgifter raderas permanent eller mjukt?
- Hur ska datum, tider och tidszoner representeras?
- Ska H2 behållas för lokal utveckling efter PostgreSQL-införandet?
- Hur ska användare senare kunna redigeras eller raderas, särskilt när de är
@ -432,5 +450,21 @@ Nuvarande aktiva användarval är uttryckligen inte autentisering.
## Ändringshistorik
- 2026-07-28: Feature 8 verifierades och mergades. Serverbekräftad redigering
av titel, beskrivning och poäng infördes, och Feature 9 blev nästa planerade
produktfeature.
- 2026-07-27: Feature 8 implementerades och verifierades automatiskt på
feature-branchen. Manuell verifiering och merge återstår.
- 2026-07-27: Feature 7 verifierades och mergades. Permanent,
serverbekräftad radering infördes, och Feature 8 blev nästa planerade
produktfeature.
- 2026-07-27: Feature 6 verifierades och mergades. Optimistisk drag-and-drop
med full rollback infördes, och Feature 7 blev nästa planerade
produktfeature.
- 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
316 planerades och Feature 17 markerades som villkorad.

View File

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

View File

@ -8,6 +8,12 @@ importers:
.:
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:
specifier: 19.2.8
version: 19.2.8
@ -115,6 +121,27 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
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':
resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
@ -145,6 +172,9 @@ packages:
'@oxc-project/types@0.139.0':
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':
resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -961,6 +991,45 @@ snapshots:
'@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':
dependencies:
'@emnapi/wasi-threads': 1.2.2
@ -990,6 +1059,8 @@ snapshots:
'@oxc-project/types@0.139.0': {}
'@preact/signals-core@1.14.4': {}
'@rolldown/binding-android-arm64@1.1.5':
optional: true
@ -1476,8 +1547,7 @@ snapshots:
dependencies:
punycode: 2.3.1
tslib@2.8.1:
optional: true
tslib@2.8.1: {}
typescript@7.0.2:
optionalDependencies:

File diff suppressed because it is too large Load Diff

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

@ -1,6 +1,17 @@
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 = {
id: string
@ -8,15 +19,25 @@ type Task = {
description: string | null
status: TaskStatus
points: number
assignee: Assignee | null
createdAt: string
}
type ApiError = {
code?: string
message?: string
}
type TaskDetails = {
title: string
description: string | null
points: number
}
type TaskBoardProps = {
activeUserId: string
activeUserName: string
users: UserSummary[]
onLogOut: () => void
}
@ -26,10 +47,18 @@ 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 [editingTask, setEditingTask] = useState<Task | null>(null)
const [editError, setEditError] = useState('')
const [deletingTask, setDeletingTask] = useState<Task | null>(null)
const [deleteError, setDeleteError] = useState('')
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 +81,232 @@ 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,
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')
}
const openEditTask = (task: Task) => {
if (pendingTaskIdsRef.current.has(task.id)) {
return
}
setEditError('')
setEditingTask(task)
}
const closeEditTask = () => {
if (editingTask && pendingTaskIdsRef.current.has(editingTask.id)) {
return
}
setEditError('')
setEditingTask(null)
}
const updateDetails = async (task: Task, details: TaskDetails) => {
if (!beginTaskRequest(task.id)) {
return
}
setEditError('')
try {
const response = await fetch(`/api/tasks/${task.id}/details`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(details),
})
if (!response.ok) {
const apiError = (await response.json().catch(() => ({}))) as ApiError
if (response.status === 404 && apiError.code === 'TASK_NOT_FOUND') {
setTasks((current) => current.filter((candidate) => candidate.id !== task.id))
setEditingTask(null)
return
}
setEditError(apiError.message ?? 'Det gick inte att spara ändringarna. Försök igen.')
return
}
replaceTask((await response.json()) as Task)
setEditingTask(null)
} catch {
setEditError('Det gick inte att spara ändringarna. Försök igen.')
} finally {
finishTaskRequest(task.id)
}
}
const openDeleteTask = (task: Task) => {
if (pendingTaskIdsRef.current.has(task.id)) {
return
}
setDeleteError('')
setDeletingTask(task)
}
const closeDeleteTask = () => {
if (deletingTask && pendingTaskIdsRef.current.has(deletingTask.id)) {
return
}
setDeleteError('')
setDeletingTask(null)
}
const deleteTask = async (task: Task) => {
if (!beginTaskRequest(task.id)) {
return
}
setDeleteError('')
try {
const response = await fetch(`/api/tasks/${task.id}`, { method: 'DELETE' })
if (response.status === 204) {
setTasks((current) => current.filter((candidate) => candidate.id !== task.id))
setDeletingTask(null)
return
}
const apiError = (await response.json().catch(() => ({}))) as ApiError
if (response.status === 404 && apiError.code === 'TASK_NOT_FOUND') {
setTasks((current) => current.filter((candidate) => candidate.id !== task.id))
setDeletingTask(null)
return
}
setDeleteError('Det gick inte att radera uppgiften. Försök igen.')
} catch {
setDeleteError('Det gick inte att radera uppgiften. Försök igen.')
} finally {
finishTaskRequest(task.id)
}
}
return (
<main className="task-app">
<header className="app-header">
@ -84,29 +339,31 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
</button>
</div>
<TaskDragDropProvider onTaskDrop={dropTask}>
<section className="board" aria-label="Uppgiftsbräda">
{columns.map((column) => (
<section className="board-column" key={column.status} aria-labelledby={column.status}>
<h2 id={column.status}>{column.title}</h2>
<div className="task-list">
{tasks
.filter((task) => task.status === column.status)
.map((task) => (
<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>}
</article>
))}
</div>
</section>
<TaskColumn
column={column}
tasks={tasks.filter((task) => task.status === column.status)}
users={users}
editingAssigneeTaskId={editingAssigneeTaskId}
pendingTaskIds={pendingTaskIds}
taskErrors={taskErrors}
onEditAssignee={setEditingAssigneeTaskId}
onChangeAssignee={(task, assigneeId) => void updateAssignee(task, assigneeId)}
onChangeStatus={(task, status) =>
void updateStatus(task, status, 'server-confirmed')
}
onEdit={openEditTask}
onDelete={openDeleteTask}
/>
))}
</section>
</TaskDragDropProvider>
{showCreateTask && (
<CreateTaskModal
users={users}
onClose={() => setShowCreateTask(false)}
onCreated={(task) => {
setTasks((currentTasks) => [...currentTasks, task])
@ -114,19 +371,557 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
}}
/>
)}
{editingTask && (
<EditTaskModal
task={editingTask}
pending={pendingTaskIds.has(editingTask.id)}
error={editError}
onClose={closeEditTask}
onSave={(details) => void updateDetails(editingTask, details)}
/>
)}
{deletingTask && (
<DeleteTaskModal
task={deletingTask}
pending={pendingTaskIds.has(deletingTask.id)}
error={deleteError}
onClose={closeDeleteTask}
onConfirm={() => void deleteTask(deletingTask)}
/>
)}
</main>
)
}
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
onEdit: (task: Task) => void
onDelete: (task: Task) => void
}
function TaskColumn({
column,
tasks,
users,
editingAssigneeTaskId,
pendingTaskIds,
taskErrors,
onEditAssignee,
onChangeAssignee,
onChangeStatus,
onEdit,
onDelete,
}: 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)}
onEdit={() => onEdit(task)}
onDelete={() => onDelete(task)}
/>
))}
</div>
</section>
)
}
type TaskCardProps = {
task: Task
users: UserSummary[]
editingAssignee: boolean
pending: boolean
error?: string
onEditAssignee: () => void
onChangeAssignee: (assigneeId: string) => void
onChangeStatus: (status: TaskStatus) => void
onEdit: () => void
onDelete: () => void
}
function TaskCard({
task,
users,
editingAssignee,
pending,
error,
onEditAssignee,
onChangeAssignee,
onChangeStatus,
onEdit,
onDelete,
}: 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>
<div className="task-card-actions">
<span className="points-badge">{task.points} p</span>
<button
type="button"
className="task-edit-button"
aria-label={`Redigera ${task.title}`}
disabled={pending}
onPointerDown={(event) => event.stopPropagation()}
onClick={onEdit}
>
<EditIcon />
</button>
<button
type="button"
className="task-delete-button"
aria-label={`Radera ${task.title}`}
disabled={pending}
onPointerDown={(event) => event.stopPropagation()}
onClick={onDelete}
>
<TrashIcon />
</button>
</div>
</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>
)
}
function EditIcon() {
return (
<svg
viewBox="0 0 24 24"
width="19"
height="19"
aria-hidden="true"
focusable="false"
>
<path
d="m4 20 4.5-1 10-10a2.1 2.1 0 0 0-3-3l-10 10L4 20Zm10-12 3 3"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
function TrashIcon() {
return (
<svg
viewBox="0 0 24 24"
width="19"
height="19"
aria-hidden="true"
focusable="false"
>
<path
d="M4 7h16M9 7V4h6v3m-8 0 1 13h8l1-13M10 11v5m4-5v5"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
type EditTaskModalProps = {
task: Task
pending: boolean
error: string
onClose: () => void
onSave: (details: TaskDetails) => void
}
function EditTaskModal({ task, pending, error, onClose, onSave }: EditTaskModalProps) {
const [title, setTitle] = useState(task.title)
const [description, setDescription] = useState(task.description ?? '')
const [points, setPoints] = useState(String(task.points))
const [validationError, setValidationError] = useState('')
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !pending) {
onClose()
}
}
window.addEventListener('keydown', closeOnEscape)
return () => window.removeEventListener('keydown', closeOnEscape)
}, [onClose, pending])
const closeFromBackdrop = (event: MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget && !pending) {
onClose()
}
}
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (pending) {
return
}
const trimmedTitle = title.trim()
const trimmedDescription = description.trim()
const numericPoints = Number(points)
if (!trimmedTitle || [...trimmedTitle].length > 100) {
setValidationError('Titeln måste innehålla mellan 1 och 100 tecken.')
return
}
if ([...trimmedDescription].length > 500) {
setValidationError('Beskrivningen får innehålla högst 500 tecken.')
return
}
if (
!points.trim() ||
!Number.isInteger(numericPoints) ||
numericPoints < 1 ||
numericPoints > 99
) {
setValidationError('Poäng måste vara ett heltal mellan 1 och 99.')
return
}
setValidationError('')
onSave({
title: trimmedTitle,
description: trimmedDescription || null,
points: numericPoints,
})
}
return (
<div className="modal-backdrop" onMouseDown={closeFromBackdrop}>
<section
className="modal edit-task-modal"
role="dialog"
aria-modal="true"
aria-labelledby="edit-task-title"
>
<div className="modal-header">
<h2 id="edit-task-title">Redigera uppgift</h2>
<button
type="button"
className="close-button"
aria-label="Stäng"
disabled={pending}
onClick={onClose}
>
×
</button>
</div>
<form noValidate onSubmit={submit}>
<label htmlFor="edit-task-title-field">Titel</label>
<input
id="edit-task-title-field"
autoFocus
value={title}
disabled={pending}
onChange={(event) => setTitle(event.target.value)}
/>
<label htmlFor="edit-task-description">Beskrivning (valfri)</label>
<textarea
id="edit-task-description"
rows={5}
value={description}
disabled={pending}
onChange={(event) => setDescription(event.target.value)}
/>
<label htmlFor="edit-task-points">Poäng</label>
<input
id="edit-task-points"
type="number"
min="1"
max="99"
step="1"
value={points}
disabled={pending}
onChange={(event) => setPoints(event.target.value)}
/>
{(validationError || error) && (
<p className="error" role="alert">
{validationError || error}
</p>
)}
<div className="edit-task-actions">
<button
type="button"
className="secondary compact"
disabled={pending}
onClick={onClose}
>
Avbryt
</button>
<button type="submit" disabled={pending}>
{pending ? 'Sparar…' : 'Spara'}
</button>
</div>
</form>
</section>
</div>
)
}
type DeleteTaskModalProps = {
task: Task
pending: boolean
error: string
onClose: () => void
onConfirm: () => void
}
function DeleteTaskModal({
task,
pending,
error,
onClose,
onConfirm,
}: DeleteTaskModalProps) {
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !pending) {
onClose()
}
}
window.addEventListener('keydown', closeOnEscape)
return () => window.removeEventListener('keydown', closeOnEscape)
}, [onClose, pending])
const closeFromBackdrop = (event: MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget && !pending) {
onClose()
}
}
return (
<div className="modal-backdrop" onMouseDown={closeFromBackdrop}>
<section
className="modal delete-task-modal"
role="dialog"
aria-modal="true"
aria-labelledby="delete-task-title"
>
<div className="modal-header">
<h2 id="delete-task-title">Radera uppgift?</h2>
</div>
<p>
Är du säker att du vill radera <strong>{task.title}</strong>? Uppgiften
raderas permanent och kan inte återställas.
</p>
{error && (
<p className="error" role="alert">
{error}
</p>
)}
<div className="delete-task-actions">
<button
type="button"
className="secondary compact"
autoFocus
disabled={pending}
onClick={onClose}
>
Avbryt
</button>
<button
type="button"
className="danger"
disabled={pending}
onClick={onConfirm}
>
Radera
</button>
</div>
</section>
</div>
)
}
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 +986,7 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
title: trimmedTitle,
description: trimmedDescription || null,
points: numericPoints,
assigneeId: assigneeId || null,
}),
})
@ -264,6 +1060,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

@ -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,
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%;
@ -147,8 +158,15 @@ textarea {
.board-column {
min-height: 20rem;
padding: 1rem;
border: 1px solid transparent;
border-radius: 0.75rem;
background: #e5e7eb;
transition: border-color 120ms ease, background-color 120ms ease;
}
.board-column-drop-target {
border-color: #93c5fd;
background: #e0e7ff;
}
.board-column h2 {
@ -169,11 +187,72 @@ textarea {
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 p {
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;
@ -181,6 +260,46 @@ textarea {
gap: 0.75rem;
}
.task-card-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.35rem;
}
.task-edit-button,
.task-delete-button {
display: inline-grid;
width: 2.5rem;
height: 2.5rem;
padding: 0;
place-items: center;
color: #64748b;
background: transparent;
}
.task-edit-button:hover,
.task-edit-button:focus-visible {
color: #1d4ed8;
background: #dbeafe;
}
.task-edit-button:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.task-delete-button:hover,
.task-delete-button:focus-visible {
color: #991b1b;
background: #fee2e2;
}
.task-delete-button:focus-visible {
outline: 2px solid #dc2626;
outline-offset: 2px;
}
.points-badge {
flex: 0 0 auto;
padding: 0.2rem 0.5rem;
@ -198,6 +317,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;
@ -229,6 +367,40 @@ textarea {
margin: 0;
}
.delete-task-modal p {
margin: 0 0 1rem;
}
.edit-task-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1rem;
}
.edit-task-actions .secondary {
margin-top: 0;
}
.delete-task-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.delete-task-actions .secondary {
margin-top: 0;
}
.danger {
background: #b91c1c;
}
.danger:hover,
.danger:focus-visible {
background: #991b1b;
}
.close-button {
padding: 0.2rem 0.55rem;
color: #475569;

View File

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