8 Commits

29 changed files with 1661 additions and 70 deletions

View File

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

View File

@ -2,10 +2,15 @@ package se.rubble.hemhub.api;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import se.rubble.hemhub.task.InvalidTaskException; import se.rubble.hemhub.task.InvalidTaskException;
import se.rubble.hemhub.task.InvalidTaskAssignmentException;
import se.rubble.hemhub.task.AssigneeNotFoundException;
import se.rubble.hemhub.task.TaskAssignmentConflictException;
import se.rubble.hemhub.task.TaskNotFoundException;
import se.rubble.hemhub.user.InvalidUserNameException; import se.rubble.hemhub.user.InvalidUserNameException;
import se.rubble.hemhub.user.UserNameAlreadyExistsException; import se.rubble.hemhub.user.UserNameAlreadyExistsException;
@ -33,5 +38,39 @@ public class ApiExceptionHandler {
return ResponseEntity.badRequest() return ResponseEntity.badRequest()
.body(new ApiError("INVALID_TASK", exception.getMessage())); .body(new ApiError("INVALID_TASK", exception.getMessage()));
} }
}
@ExceptionHandler(InvalidTaskAssignmentException.class)
public ResponseEntity<ApiError> handleInvalidTaskAssignment(
InvalidTaskAssignmentException exception) {
return ResponseEntity.badRequest()
.body(new ApiError("INVALID_TASK_ASSIGNMENT", exception.getMessage()));
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiError> handleInvalidPathParameter() {
return ResponseEntity.badRequest()
.body(new ApiError(
"INVALID_TASK_ASSIGNMENT",
"Uppgifts-id måste vara ett giltigt UUID."));
}
@ExceptionHandler(TaskNotFoundException.class)
public ResponseEntity<ApiError> handleTaskNotFound() {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiError("TASK_NOT_FOUND", "Uppgiften finns inte."));
}
@ExceptionHandler(AssigneeNotFoundException.class)
public ResponseEntity<ApiError> handleAssigneeNotFound() {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiError("USER_NOT_FOUND", "Användaren finns inte."));
}
@ExceptionHandler(TaskAssignmentConflictException.class)
public ResponseEntity<ApiError> handleTaskAssignmentConflict() {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ApiError(
"TASK_ASSIGNMENT_NOT_ALLOWED",
"Ansvarig kan endast ändras för väntande uppgifter."));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -29,6 +29,7 @@ Den ansvarar för:
- hämtning och presentation av användare och uppgifter; - hämtning och presentation av användare och uppgifter;
- lokalt val av aktiv användare; - lokalt val av aktiv användare;
- formulär för att skapa användare och uppgifter; - formulär för att skapa användare och uppgifter;
- val och visning av ansvarig användare på uppgifter;
- klientnära validering och begripliga felmeddelanden; - klientnära validering och begripliga felmeddelanden;
- uppgiftsbrädan med kolumnerna Väntande, Pågående och Klart. - uppgiftsbrädan med kolumnerna Väntande, Pågående och Klart.
@ -60,12 +61,13 @@ Aktuella endpoints:
- `POST /api/users` - `POST /api/users`
- `GET /api/tasks` - `GET /api/tasks`
- `POST /api/tasks` - `POST /api/tasks`
- `PUT /api/tasks/{taskId}/assignee`
### Databas och migreringar ### Databas och migreringar
Lokal körning använder en filbaserad H2-databas under `backend/data`. Katalogen Lokal körning använder en H2-databas i minnet. Databasen finns under
ignoreras av Git. Automatiska backendtester använder en separat H2-databas i backendprocessens livstid och lokal utvecklingsdata återställs när backend
minnet. startas om. Automatiska backendtester använder en separat H2-databas i minnet.
Båda anslutningarna använder H2:s `MODE=PostgreSQL`, Båda anslutningarna använder H2:s `MODE=PostgreSQL`,
`DATABASE_TO_LOWER=TRUE` och `DEFAULT_NULL_ORDERING=HIGH`. Det är en verifierbar `DATABASE_TO_LOWER=TRUE` och `DEFAULT_NULL_ORDERING=HIGH`. Det är en verifierbar
@ -76,6 +78,8 @@ Flyway kör migreringarna:
- `V1__create_users.sql` - `V1__create_users.sql`
- `V2__create_tasks.sql` - `V2__create_tasks.sql`
- `V3__add_task_points.sql`
- `V4__add_task_assignee.sql`
Hibernate är konfigurerat med `ddl-auto=validate`; Flyway skapar schemat och Hibernate är konfigurerat med `ddl-auto=validate`; Flyway skapar schemat och
Hibernate validerar entiteterna mot det. Hibernate validerar entiteterna mot det.
@ -102,11 +106,20 @@ En uppgift lagras i tabellen `task` med:
- `title`: obligatorisk titel, högst 100 tecken; - `title`: obligatorisk titel, högst 100 tecken;
- `description`: valfri beskrivning, högst 500 tecken; - `description`: valfri beskrivning, högst 500 tecken;
- `status`: `WAITING`, `IN_PROGRESS` eller `COMPLETED`; - `status`: `WAITING`, `IN_PROGRESS` eller `COMPLETED`;
- `points`: obligatoriskt heltal mellan 1 och 99;
- `assignee_id`: nullable främmande nyckel till `app_user`;
- `created_at`: en `Instant`, lagrad som `TIMESTAMP WITH TIME ZONE`. - `created_at`: en `Instant`, lagrad som `TIMESTAMP WITH TIME ZONE`.
Status lagras som enumens textvärde genom `EnumType.STRING`. Nya uppgifter får Status lagras som enumens textvärde genom `EnumType.STRING`. Nya uppgifter får
alltid status `WAITING`. Det finns ingen relation mellan uppgifter och alltid status `WAITING`. Poängintervallet skyddas i backend och med en
användare; alla aktiva användare ser samma uppgiftslista. databasconstraint. En uppgift kan vara otilldelad eller referera till exakt en
ansvarig användare. Relationen hämtas tillsammans med uppgifterna när de listas,
så API-responsen kan innehålla ansvarigs `id` och `name` utan separata
frontend-anrop. Alla aktiva användare ser samma uppgiftslista.
Ansvarig är valfri vid skapande. Endast väntande uppgifter kan få ändrad
ansvarig genom det särskilda tilldelnings-API:t. Tilldelning ändrar aldrig
uppgiftens status.
### Aktiv användare ### Aktiv användare
@ -121,8 +134,9 @@ lokalt per browser och utgör inte autentisering eller behörighetskontroll.
### Felhantering ### Felhantering
Backend använder ett litet gemensamt JSON-format med `code` och `message`. Backend använder ett litet gemensamt JSON-format med `code` och `message`.
`ApiExceptionHandler` översätter kända valideringsfel till `400 Bad Request` `ApiExceptionHandler` översätter kända valideringsfel till `400 Bad Request`,
och dubbletter av användarnamn till `409 Conflict`. saknade uppgifter eller användare till `404 Not Found` och dubbletter eller
otillåtna tilldelningsändringar till `409 Conflict`.
Frontend skiljer mellan fel vid hämtning och skapande. Hämtfel kan Frontend skiljer mellan fel vid hämtning och skapande. Hämtfel kan
återförsökas. Formulärfel visas nära formuläret och inmatningen behålls vid återförsökas. Formulärfel visas nära formuläret och inmatningen behålls vid

View File

@ -0,0 +1,443 @@
# Feature 3 Uppgiftspoäng
## Status
Färdig och mergad till `main`.
## 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
Backendtesterna verifierar 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 följer den befintliga teststilen och utökar de tidigare
uppgifts-API-testerna.
## Frontendtester
Frontendtesterna verifierar 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 är inte beroende av en viss pixelplacering eller detaljerad CSS.
## Manuell verifiering
Följande verifierades 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.
## Relaterade commits
- `059d4da9214969ed3e28592178160da6de614b4d` `feat: add task points`
- `2e62261f49bb3142e28882483e41e0250ab11c5f` merge till `main`

View File

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

View File

@ -34,20 +34,24 @@ Följande statusvärden används:
## Nuvarande läge ## Nuvarande läge
Feature 02 är klara. Den aktuella applikationen har: Feature 04 är klara. Den aktuella applikationen har:
- ett monorepo med separat React/Vite-frontend och Spring Boot-backend; - ett monorepo med separat React/Vite-frontend och Spring Boot-backend;
- centralt lagrade användare och ett lokalt browserval av aktiv användare; - centralt lagrade användare och ett lokalt browserval av aktiv användare;
- gemensamma uppgifter med titel, valfri beskrivning och status; - gemensamma uppgifter med titel, valfri beskrivning, status och poäng;
- skapande och listning av uppgifter; - skapande och listning av uppgifter;
- valfri tilldelning av högst en ansvarig användare per uppgift;
- tilldelning, byte och borttagning av ansvarig för väntande uppgifter;
- en bräda med Väntande, Pågående och Klart; - en bräda med Väntande, Pågående och Klart;
- nya uppgifter som alltid skapas med status `WAITING`. - nya uppgifter som alltid skapas med status `WAITING`.
Det finns ännu inga poäng, uppgiftstilldelningar, statusändringar, Tilldelning och status är separata egenskaper; tilldelningsflödet ändrar inte
drag-and-drop, redigeringar, raderingar, deadlines eller återkommande uppgifter. uppgiftens status. Det finns ännu inga statusändringar, drag-and-drop,
redigeringar, raderingar, deadlines eller återkommande uppgifter.
Nuvarande användarval är inte autentisering. Nuvarande användarval är inte autentisering.
**Feature 3 Uppgiftspoäng är nästa planerade produktfeature.** **Feature 5 Statusändring och statusregler är nästa planerade
produktfeature.**
## Featureöversikt ## Featureöversikt
@ -56,8 +60,8 @@ Nuvarande användarval är inte autentisering.
| 0 Projektgrund | Klar | | Körbar frontend, backend och lokal API-koppling | | 0 Projektgrund | Klar | | Körbar frontend, backend och lokal API-koppling |
| 1 Användarval | Klar | 0 | Centrala användare och lokalt aktivt användar-id | | 1 Användarval | Klar | 0 | Centrala användare och lokalt aktivt användar-id |
| 2 Skapa uppgifter | Klar | 01 | Gemensamma uppgifter och trekolumnsbräda | | 2 Skapa uppgifter | Klar | 01 | Gemensamma uppgifter och trekolumnsbräda |
| 3 Uppgiftspoäng | Planerad | 2 | Poäng på uppgifter | | 3 Uppgiftspoäng | Klar | 2 | Poäng på uppgifter |
| 4 Tilldelning | Planerad | 12 | Valfri ansvarig användare | | 4 Tilldelning | Klar | 12 | Valfri ansvarig användare |
| 5 Statusändring | Planerad | 4 | Backendstyrda statusövergångar | | 5 Statusändring | Planerad | 4 | Backendstyrda statusövergångar |
| 6 Drag-and-drop | Planerad | 5 | Kortflytt via status-API | | 6 Drag-and-drop | Planerad | 5 | Kortflytt via status-API |
| 7 Radera uppgift | Planerad | 2 | Bekräftad radering | | 7 Radera uppgift | Planerad | 2 | Bekräftad radering |
@ -109,7 +113,7 @@ interaktiv brädhantering införs.
### Feature 3 Uppgiftspoäng ### Feature 3 Uppgiftspoäng
**Status:** Planerad **Status:** Klar
**Beroenden:** Feature 2 **Beroenden:** Feature 2
@ -124,14 +128,13 @@ interaktiv brädhantering införs.
Feature 3 ligger först eftersom poäng blir ett centralt uppgiftsfält som senare Feature 3 ligger först eftersom poäng blir ett centralt uppgiftsfält som senare
ska kunna redigeras och historikföras. ska kunna redigeras och historikföras.
**Öppna frågor:** Poängskalan är beslutad till alla heltal mellan 1 och 99. V3-migreringen ger
eventuella befintliga uppgifter värdet `1` innan kolumnen görs obligatorisk;
- exakt poängskala; databasen har inget permanent defaultvärde.
- standardvärde för befintliga uppgifter.
### Feature 4 Tilldelning av uppgifter ### Feature 4 Tilldelning av uppgifter
**Status:** Planerad **Status:** Klar
**Beroenden:** Feature 1 och Feature 2 **Beroenden:** Feature 1 och Feature 2
@ -142,13 +145,10 @@ ska kunna redigeras och historikföras.
- visa ansvarig på uppgiftskort; - visa ansvarig på uppgiftskort;
- kunna ändra ansvarig på en befintlig uppgift. - kunna ändra ansvarig på en befintlig uppgift.
En väntande uppgift får vara tilldelad eller otilldelad. Tilldelning införs före En väntande uppgift får vara tilldelad eller otilldelad och har högst en
statusändring eftersom en pågående uppgift senare måste ha en ansvarig. ansvarig. Tilldelning införs före statusändring eftersom en pågående uppgift
senare måste ha en ansvarig. Hur borttagna användare ska hanteras är fortsatt
**Öppna frågor:** öppet tills användarradering införs.
- om en uppgift ska ha endast en ansvarig;
- hur borttagna användare ska hanteras när användarradering införs.
### Feature 5 Statusändring och statusregler ### Feature 5 Statusändring och statusregler
@ -165,7 +165,9 @@ statusändring eftersom en pågående uppgift senare måste ha en ansvarig.
`IN_PROGRESS` kräver en ansvarig användare. Statusflödet införs före `IN_PROGRESS` kräver en ansvarig användare. Statusflödet införs före
drag-and-drop så att affärsregeln och API:t kan verifieras utan att samtidigt drag-and-drop så att affärsregeln och API:t kan verifieras utan att samtidigt
bygga en komplex interaktion. bygga en komplex interaktion. Feature 5 återanvänder Feature 4:s
tilldelningsmodell och särskilda API för ansvarig; statusändring sker i ett
separat statusflöde.
**Öppna frågor:** **Öppna frågor:**
@ -422,8 +424,6 @@ Nuvarande aktiva användarval är uttryckligen inte autentisering.
## Öppna tvärgående frågor ## Öppna tvärgående frågor
- Vilken poängskala ska användas och vilket standardvärde får befintliga
uppgifter?
- Ska uppgifter raderas permanent eller mjukt? - Ska uppgifter raderas permanent eller mjukt?
- Hur ska datum, tider och tidszoner representeras? - Hur ska datum, tider och tidszoner representeras?
- Ska H2 behållas för lokal utveckling efter PostgreSQL-införandet? - Ska H2 behållas för lokal utveckling efter PostgreSQL-införandet?
@ -435,5 +435,7 @@ Nuvarande aktiva användarval är uttryckligen inte autentisering.
## Ändringshistorik ## Ändringshistorik
- 2026-07-26: Feature 3 och Feature 4 markerades som klara efter verifiering och
merge. Feature 5 blev nästa planerade produktfeature.
- 2026-07-26: Roadmapen etablerades. Feature 02 markerades som klara, Feature - 2026-07-26: Roadmapen etablerades. Feature 02 markerades som klara, Feature
316 planerades och Feature 17 markerades som villkorad. 316 planerades och Feature 17 markerades som villkorad.

View File

@ -21,6 +21,8 @@ const tasks = [
title: 'Dammsuga', title: 'Dammsuga',
description: 'Bottenvåningen', description: 'Bottenvåningen',
status: 'WAITING', status: 'WAITING',
points: 7,
assignee: null,
createdAt: '2026-07-24T10:00:00Z', createdAt: '2026-07-24T10:00:00Z',
}, },
{ {
@ -28,6 +30,8 @@ const tasks = [
title: 'Diska', title: 'Diska',
description: null, description: null,
status: 'IN_PROGRESS', status: 'IN_PROGRESS',
points: 3,
assignee: { id: users[1].id, name: users[1].name },
createdAt: '2026-07-24T10:01:00Z', createdAt: '2026-07-24T10:01:00Z',
}, },
{ {
@ -35,6 +39,8 @@ const tasks = [
title: 'Vattna blommor', title: 'Vattna blommor',
description: null, description: null,
status: 'COMPLETED', status: 'COMPLETED',
points: 5,
assignee: null,
createdAt: '2026-07-24T10:02:00Z', createdAt: '2026-07-24T10:02:00Z',
}, },
] ]
@ -167,12 +173,14 @@ test('brädan visar tre kolumner och grupperar hämtade uppgifter', async () =>
render(<App />) render(<App />)
const waiting = await screen.findByRole('region', { name: 'Väntande' }) await screen.findByText('Dammsuga')
const waiting = screen.getByRole('region', { name: 'Väntande' })
const inProgress = screen.getByRole('region', { name: 'Pågående' }) const inProgress = screen.getByRole('region', { name: 'Pågående' })
const completed = screen.getByRole('region', { name: 'Klart' }) const completed = screen.getByRole('region', { name: 'Klart' })
expect(within(waiting).getByText('Dammsuga')).toBeInTheDocument() expect(within(waiting).getByText('Dammsuga')).toBeInTheDocument()
expect(within(waiting).getByText('Bottenvåningen')).toBeInTheDocument() expect(within(waiting).getByText('Bottenvåningen')).toBeInTheDocument()
expect(within(waiting).getByText('7 p')).toBeInTheDocument()
expect(within(inProgress).getByText('Diska')).toBeInTheDocument() expect(within(inProgress).getByText('Diska')).toBeInTheDocument()
expect(within(completed).getByText('Vattna blommor')).toBeInTheDocument() expect(within(completed).getByText('Vattna blommor')).toBeInTheDocument()
expect(screen.queryByText(/Inga uppgifter/i)).not.toBeInTheDocument() expect(screen.queryByText(/Inga uppgifter/i)).not.toBeInTheDocument()
@ -187,6 +195,14 @@ test('Ny uppgift öppnar modalen med fokus i titelfältet', async () => {
expect(screen.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument() expect(screen.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument()
expect(screen.getByLabelText('Titel')).toHaveFocus() expect(screen.getByLabelText('Titel')).toHaveFocus()
expect(screen.getByLabelText('Poäng')).toHaveValue(1)
expect(screen.getByLabelText('Tilldela')).toHaveValue('')
expect(within(screen.getByLabelText('Tilldela')).getByRole('option', { name: 'Ingen' }))
.toBeInTheDocument()
expect(within(screen.getByLabelText('Tilldela')).getByRole('option', { name: 'Urban' }))
.toBeInTheDocument()
expect(within(screen.getByLabelText('Tilldela')).getByRole('option', { name: 'Anna' }))
.toBeInTheDocument()
}) })
test.each([ test.each([
@ -220,6 +236,7 @@ test.each([
fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), { fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), {
target: { value: 'Bottenvåningen' }, target: { value: 'Bottenvåningen' },
}) })
fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: '7' } })
close() close()
@ -229,6 +246,7 @@ test.each([
expect(screen.getByLabelText('Titel')).toHaveValue('') expect(screen.getByLabelText('Titel')).toHaveValue('')
expect(screen.getByLabelText('Beskrivning (valfri)')).toHaveValue('') expect(screen.getByLabelText('Beskrivning (valfri)')).toHaveValue('')
expect(screen.getByLabelText('Poäng')).toHaveValue(1)
}) })
test('en skapad uppgift visas längst ned i Väntande och modalen stängs', async () => { test('en skapad uppgift visas längst ned i Väntande och modalen stängs', async () => {
@ -237,6 +255,8 @@ test('en skapad uppgift visas längst ned i Väntande och modalen stängs', asyn
title: 'Putsa fönster', title: 'Putsa fönster',
description: 'Köket', description: 'Köket',
status: 'WAITING', status: 'WAITING',
points: 7,
assignee: { id: users[1].id, name: users[1].name },
createdAt: '2026-07-24T10:03:00Z', createdAt: '2026-07-24T10:03:00Z',
} }
window.localStorage.setItem('hemhub.activeUserId', users[0].id) window.localStorage.setItem('hemhub.activeUserId', users[0].id)
@ -253,6 +273,8 @@ test('en skapad uppgift visas längst ned i Väntande och modalen stängs', asyn
fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), { fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), {
target: { value: ' Köket ' }, target: { value: ' Köket ' },
}) })
fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: '7' } })
fireEvent.change(screen.getByLabelText('Tilldela'), { target: { value: users[1].id } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' })) fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' }))
await waitFor(() => await waitFor(() =>
@ -260,14 +282,170 @@ test('en skapad uppgift visas längst ned i Väntande och modalen stängs', asyn
) )
const waiting = screen.getByRole('region', { name: 'Väntande' }) const waiting = screen.getByRole('region', { name: 'Väntande' })
expect(within(waiting).getAllByRole('article').map((card) => card.textContent)).toEqual([ expect(within(waiting).getAllByRole('article').map((card) => card.textContent)).toEqual([
'DammsugaBottenvåningen', 'Dammsuga7 pBottenvåningenTa uppgift',
'Putsa fönsterKöket', 'Putsa fönster7 pKöketAnna',
]) ])
expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', { expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Putsa fönster', description: 'Köket' }), body: JSON.stringify({
title: 'Putsa fönster',
description: 'Köket',
points: 7,
assigneeId: users[1].id,
}),
}) })
fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' }))
expect(screen.getByLabelText('Poäng')).toHaveValue(1)
expect(screen.getByLabelText('Tilldela')).toHaveValue('')
})
test('Ingen skickas som null när en uppgift skapas', async () => {
const createdTask = {
...tasks[0],
id: '00000000-0000-0000-0000-000000000010',
title: 'Torka bordet',
}
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([]))
fetchMock.mockResolvedValueOnce(jsonResponse(createdTask, 201))
render(<App />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' }))
fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Torka bordet' } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' }))
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3))
expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Torka bordet',
description: null,
points: 1,
assigneeId: null,
}),
})
})
test('kort visar ansvarig och endast väntande uppgifter har redigerbar kontroll', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
mockUsersAndTasks(users, tasks)
render(<App />)
const waitingCard = (await screen.findByText('Dammsuga')).closest('article')
const inProgressCard = screen.getByText('Diska').closest('article')
const completedCard = screen.getByText('Vattna blommor').closest('article')
expect(waitingCard).not.toBeNull()
expect(inProgressCard).not.toBeNull()
expect(completedCard).not.toBeNull()
expect(within(waitingCard!).getByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
.toHaveTextContent('Ta uppgift')
expect(within(inProgressCard!).getByText('Anna')).toBeInTheDocument()
expect(within(inProgressCard!).queryByRole('button', { name: /ansvarig/i })).not.toBeInTheDocument()
expect(within(completedCard!).getByText('Otilldelad')).toBeInTheDocument()
})
test('val av ansvarig anropar endpointen och uppdaterar kortet efter svar', async () => {
const updatedTask = { ...tasks[0], assignee: { id: users[1].id, name: users[1].name } }
let resolveAssignment!: (response: Response) => void
const assignmentResponse = new Promise<Response>((resolve) => {
resolveAssignment = resolve
})
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]]))
fetchMock.mockReturnValueOnce(assignmentResponse)
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
const select = screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' })
fireEvent.change(select, { target: { value: users[1].id } })
expect(select).toBeDisabled()
expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/assignee`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assigneeId: users[1].id }),
})
resolveAssignment(jsonResponse(updatedTask))
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
.toHaveTextContent('Anna'),
)
expect(screen.queryByRole('combobox', { name: 'Ansvarig för Dammsuga' })).not.toBeInTheDocument()
})
test('val av Ingen av-tilldelar en väntande uppgift', async () => {
const assignedTask = { ...tasks[0], assignee: { id: users[1].id, name: users[1].name } }
const unassignedTask = { ...assignedTask, assignee: null }
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([assignedTask]))
fetchMock.mockResolvedValueOnce(jsonResponse(unassignedTask))
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
fireEvent.change(screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' }), {
target: { value: '' },
})
await screen.findByText('Ta uppgift')
expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/assignee`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assigneeId: null }),
})
})
test('misslyckad tilldelning behåller ansvarig och visar fel', async () => {
const assignedTask = { ...tasks[0], assignee: { id: users[1].id, name: users[1].name } }
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([assignedTask]))
fetchMock.mockResolvedValueOnce(
jsonResponse({ code: 'USER_NOT_FOUND', message: 'Användaren finns inte.' }, 404),
)
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
fireEvent.change(screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' }), {
target: { value: users[0].id },
})
expect(await screen.findByRole('alert')).toHaveTextContent('Användaren finns inte.')
expect(screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' })).toHaveValue(users[1].id)
})
test.each([
{ värde: '', beskrivning: 'tomt' },
{ värde: '0', beskrivning: 'under 1' },
{ värde: '100', beskrivning: 'över 99' },
{ värde: '1.5', beskrivning: 'decimaltal' },
])('ogiltigt poängvärde ($beskrivning) blockerar submit', async ({ värde }) => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = mockUsersAndTasks(users, [])
render(<App />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' }))
fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Dammsuga' } })
fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: värde } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'Poäng måste vara ett heltal mellan 1 och 99.',
)
expect(fetchMock).toHaveBeenCalledTimes(2)
}) })
test('formulärdata bevaras när skapande av uppgift misslyckas', async () => { test('formulärdata bevaras när skapande av uppgift misslyckas', async () => {
@ -285,14 +463,20 @@ test('formulärdata bevaras när skapande av uppgift misslyckas', async () => {
fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' })) fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' }))
const title = screen.getByLabelText('Titel') const title = screen.getByLabelText('Titel')
const description = screen.getByLabelText('Beskrivning (valfri)') const description = screen.getByLabelText('Beskrivning (valfri)')
const points = screen.getByLabelText('Poäng')
const assignee = screen.getByLabelText('Tilldela')
fireEvent.change(title, { target: { value: 'Dammsuga' } }) fireEvent.change(title, { target: { value: 'Dammsuga' } })
fireEvent.change(description, { target: { value: 'Bottenvåningen' } }) fireEvent.change(description, { target: { value: 'Bottenvåningen' } })
fireEvent.change(points, { target: { value: '7' } })
fireEvent.change(assignee, { target: { value: users[1].id } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' })) fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Uppgiften är ogiltig.') expect(await screen.findByRole('alert')).toHaveTextContent('Uppgiften är ogiltig.')
expect(screen.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument() expect(screen.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument()
expect(title).toHaveValue('Dammsuga') expect(title).toHaveValue('Dammsuga')
expect(description).toHaveValue('Bottenvåningen') expect(description).toHaveValue('Bottenvåningen')
expect(points).toHaveValue(7)
expect(assignee).toHaveValue(users[1].id)
}) })
function mockUsersAndTasks(userResponse: unknown, taskResponse: unknown) { function mockUsersAndTasks(userResponse: unknown, taskResponse: unknown) {

View File

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

View File

@ -2,11 +2,20 @@ import { FormEvent, MouseEvent, useEffect, useRef, useState } from 'react'
type TaskStatus = 'WAITING' | 'IN_PROGRESS' | 'COMPLETED' type TaskStatus = 'WAITING' | 'IN_PROGRESS' | 'COMPLETED'
type UserSummary = {
id: string
name: string
}
type Assignee = UserSummary
type Task = { type Task = {
id: string id: string
title: string title: string
description: string | null description: string | null
status: TaskStatus status: TaskStatus
points: number
assignee: Assignee | null
createdAt: string createdAt: string
} }
@ -16,6 +25,7 @@ type ApiError = {
type TaskBoardProps = { type TaskBoardProps = {
activeUserName: string activeUserName: string
users: UserSummary[]
onLogOut: () => void onLogOut: () => void
} }
@ -25,10 +35,13 @@ const columns: { status: TaskStatus; title: string }[] = [
{ status: 'COMPLETED', title: 'Klart' }, { status: 'COMPLETED', title: 'Klart' },
] ]
function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) { function TaskBoard({ activeUserName, users, onLogOut }: TaskBoardProps) {
const [tasks, setTasks] = useState<Task[]>([]) const [tasks, setTasks] = useState<Task[]>([])
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading') const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
const [showCreateTask, setShowCreateTask] = useState(false) const [showCreateTask, setShowCreateTask] = useState(false)
const [editingAssigneeTaskId, setEditingAssigneeTaskId] = useState<string | null>(null)
const [savingAssigneeTaskIds, setSavingAssigneeTaskIds] = useState<Set<string>>(new Set())
const [assignmentErrors, setAssignmentErrors] = useState<Record<string, string>>({})
const loadTasks = async () => { const loadTasks = async () => {
setLoadState('loading') setLoadState('loading')
@ -51,6 +64,49 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
void loadTasks() void loadTasks()
}, []) }, [])
const updateAssignee = async (task: Task, assigneeId: string) => {
if (savingAssigneeTaskIds.has(task.id)) {
return
}
setSavingAssigneeTaskIds((current) => new Set(current).add(task.id))
setAssignmentErrors((current) => ({ ...current, [task.id]: '' }))
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
setAssignmentErrors((current) => ({
...current,
[task.id]: apiError.message ?? 'Det gick inte att ändra ansvarig. Försök igen.',
}))
return
}
const updatedTask = (await response.json()) as Task
setTasks((current) =>
current.map((currentTask) => (currentTask.id === updatedTask.id ? updatedTask : currentTask)),
)
setEditingAssigneeTaskId(null)
} catch {
setAssignmentErrors((current) => ({
...current,
[task.id]: 'Det gick inte att ändra ansvarig. Försök igen.',
}))
} finally {
setSavingAssigneeTaskIds((current) => {
const next = new Set(current)
next.delete(task.id)
return next
})
}
}
return ( return (
<main className="task-app"> <main className="task-app">
<header className="app-header"> <header className="app-header">
@ -92,8 +148,20 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
.filter((task) => task.status === column.status) .filter((task) => task.status === column.status)
.map((task) => ( .map((task) => (
<article className="task-card" key={task.id}> <article className="task-card" key={task.id}>
<div className="task-card-header">
<h3>{task.title}</h3> <h3>{task.title}</h3>
<span className="points-badge">{task.points} p</span>
</div>
{task.description && <p>{task.description}</p>} {task.description && <p>{task.description}</p>}
<AssigneeControl
task={task}
users={users}
editing={editingAssigneeTaskId === task.id}
saving={savingAssigneeTaskIds.has(task.id)}
error={assignmentErrors[task.id]}
onEdit={() => setEditingAssigneeTaskId(task.id)}
onChange={(assigneeId) => void updateAssignee(task, assigneeId)}
/>
</article> </article>
))} ))}
</div> </div>
@ -103,6 +171,7 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
{showCreateTask && ( {showCreateTask && (
<CreateTaskModal <CreateTaskModal
users={users}
onClose={() => setShowCreateTask(false)} onClose={() => setShowCreateTask(false)}
onCreated={(task) => { onCreated={(task) => {
setTasks((currentTasks) => [...currentTasks, task]) setTasks((currentTasks) => [...currentTasks, task])
@ -114,14 +183,110 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
) )
} }
type AssigneeControlProps = {
task: Task
users: UserSummary[]
editing: boolean
saving: boolean
error?: string
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,
saving,
error,
onEdit,
onChange,
}: AssigneeControlProps) {
const displayName = task.assignee?.name ?? (task.status === 'WAITING' ? 'Ta uppgift' : 'Otilldelad')
if (task.status !== 'WAITING') {
return (
<div className="task-assignee task-assignee-static">
<UserIcon />
<span>{displayName}</span>
</div>
)
}
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={saving}
autoFocus
onChange={(event) => onChange(event.target.value)}
>
<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={saving}
onClick={onEdit}
aria-label={`Ändra ansvarig för ${task.title}`}
>
<UserIcon />
<span>{displayName}</span>
</button>
)}
{error && (
<p className="assignment-error error" role="alert">
{error}
</p>
)}
</div>
)
}
type CreateTaskModalProps = { type CreateTaskModalProps = {
users: UserSummary[]
onClose: () => void onClose: () => void
onCreated: (task: Task) => void onCreated: (task: Task) => void
} }
function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) { function CreateTaskModal({ users, onClose, onCreated }: CreateTaskModalProps) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [points, setPoints] = useState('1')
const [assigneeId, setAssigneeId] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const isSubmittingRef = useRef(false) const isSubmittingRef = useRef(false)
@ -152,6 +317,7 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
const trimmedTitle = title.trim() const trimmedTitle = title.trim()
const trimmedDescription = description.trim() const trimmedDescription = description.trim()
const numericPoints = Number(points)
if (!trimmedTitle || [...trimmedTitle].length > 100) { if (!trimmedTitle || [...trimmedTitle].length > 100) {
setError('Titeln måste innehålla mellan 1 och 100 tecken.') setError('Titeln måste innehålla mellan 1 och 100 tecken.')
@ -163,6 +329,16 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
return return
} }
if (
!points.trim() ||
!Number.isInteger(numericPoints) ||
numericPoints < 1 ||
numericPoints > 99
) {
setError('Poäng måste vara ett heltal mellan 1 och 99.')
return
}
setError('') setError('')
isSubmittingRef.current = true isSubmittingRef.current = true
setIsSubmitting(true) setIsSubmitting(true)
@ -174,6 +350,8 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
body: JSON.stringify({ body: JSON.stringify({
title: trimmedTitle, title: trimmedTitle,
description: trimmedDescription || null, description: trimmedDescription || null,
points: numericPoints,
assigneeId: assigneeId || null,
}), }),
}) })
@ -212,7 +390,7 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
× ×
</button> </button>
</div> </div>
<form onSubmit={(event) => void submit(event)}> <form noValidate onSubmit={(event) => void submit(event)}>
<label htmlFor="task-title">Titel</label> <label htmlFor="task-title">Titel</label>
<input <input
id="task-title" id="task-title"
@ -231,6 +409,39 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
onChange={(event) => setDescription(event.target.value)} onChange={(event) => setDescription(event.target.value)}
/> />
<label htmlFor="task-points">Poäng</label>
<input
id="task-points"
type="number"
min="1"
max="99"
step="1"
value={points}
disabled={isSubmitting}
aria-describedby="task-points-help"
onChange={(event) => setPoints(event.target.value)}
/>
<p id="task-points-help" className="field-help">
199 poäng beroende hur tidskrävande, besvärlig eller viktig uppgiften är.
</p>
<label className="field-label-uppercase" htmlFor="task-assignee">
Tilldela
</label>
<select
id="task-assignee"
value={assigneeId}
disabled={isSubmitting}
onChange={(event) => setAssigneeId(event.target.value)}
>
<option value="">Ingen</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
{error && ( {error && (
<p className="error" role="alert"> <p className="error" role="alert">
{error} {error}

View File

@ -23,6 +23,7 @@ h1 {
button, button,
input, input,
select,
textarea { textarea {
font: inherit; font: inherit;
} }
@ -38,6 +39,7 @@ button {
button:disabled, button:disabled,
input:disabled, input:disabled,
select:disabled,
textarea:disabled { textarea:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.65; opacity: 0.65;
@ -70,6 +72,15 @@ input {
border-radius: 0.4rem; border-radius: 0.4rem;
} }
select {
box-sizing: border-box;
width: 100%;
padding: 0.6rem;
border: 1px solid #9ca3af;
border-radius: 0.4rem;
background: white;
}
textarea { textarea {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
@ -174,12 +185,98 @@ textarea {
margin: 0; margin: 0;
} }
.task-assignment {
margin-top: 0.9rem;
}
.task-assignee {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: #475569;
font-size: 0.9rem;
}
.task-assignee-button {
padding: 0.25rem 0;
color: #2563eb;
background: transparent;
}
.task-assignee-static {
margin-top: 0.9rem;
}
.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;
}
.assignment-error {
margin-top: 0.4rem;
font-size: 0.85rem;
}
.field-label-uppercase {
color: #64748b;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.task-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.points-badge {
flex: 0 0 auto;
padding: 0.2rem 0.5rem;
border-radius: 999px;
color: #1e3a8a;
background: #dbeafe;
font-size: 0.8rem;
font-weight: 700;
line-height: 1.25;
}
.task-card p { .task-card p {
margin-top: 0.5rem; margin-top: 0.5rem;
color: #475569; color: #475569;
white-space: pre-wrap; white-space: pre-wrap;
} }
.field-help {
margin: -0.25rem 0 0;
color: #64748b;
font-size: 0.85rem;
}
.modal-backdrop { .modal-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;