13 Commits

Author SHA1 Message Date
65a6488c0b feat: add task status transitions 2026-07-27 00:46:37 +02:00
6570aad4a2 Merge pull request 'docs: align completed feature status' (#8) from chore/005-align-feature-status into main
Reviewed-on: #8
2026-07-26 23:57:51 +02:00
85afc3d3a3 docs: align completed feature status 2026-07-26 23:56:58 +02:00
d78611f5f7 Merge pull request 'feat: add task assignment' (#7) from feature/004-task-assignment into main
Reviewed-on: #7
2026-07-26 22:50:09 +02:00
aaebe888f3 feat: add task assignment 2026-07-26 22:49:06 +02:00
2e62261f49 Merge pull request 'feat: add task points' (#6) from feature/003-task-points into main
Reviewed-on: #6
2026-07-26 17:45:58 +02:00
059d4da921 feat: add task points 2026-07-26 17:43:07 +02:00
1654b54a22 Merge pull request 'docs: specify task points feature' (#5) from docs/003-task-points into main
Reviewed-on: #5
2026-07-26 16:41:52 +02:00
bad6b5afca docs: specify task points feature 2026-07-26 16:39:26 +02:00
170994b44d Merge pull request 'docs: establish HemHub feature roadmap' (#4) from chore/004-establish-roadmap into main
Reviewed-on: #4
2026-07-26 14:49:19 +02:00
a2ed8a64d6 docs: establish HemHub feature roadmap 2026-07-26 14:48:05 +02:00
1462f9dc0c Merge pull request 'docs: establish project documentation baseline' (#3) from chore/003-documentation-baseline into main
Reviewed-on: #3
2026-07-26 13:05:19 +02:00
f9246d4463 docs: establish project documentation baseline 2026-07-26 13:03:01 +02:00
43 changed files with 3571 additions and 48 deletions

View File

@ -9,4 +9,11 @@
- Affärsregler ska senare säkerställas i backend och inte enbart i frontend. - Affärsregler ska senare säkerställas i backend och inte enbart i frontend.
- Kod, tester och dokumentation ska hållas uppdaterade tillsammans. - Kod, tester och dokumentation ska hållas uppdaterade tillsammans.
- Större arkitekturella beslut ska diskuteras innan de införs. - Större arkitekturella beslut ska diskuteras innan de införs.
- Aktuell arkitektur, utvecklingsprocess, beslut och featurehistorik dokumenteras
under `docs/`.
- En feature ska uppdatera berörda dokument så att repositoryt förblir projektets
facit även efter att feature-branchen har raderats.
- Nästa feature ska väljas från `docs/roadmap.md`.
- Roadmapen ska uppdateras innan en feature delas, flyttas, ersätts eller läggs
till. En enskild dialog får inte etablera en alternativ featureplan utan att
repositoryts roadmap uppdateras.

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, tilldela och ändra status på gemensamma hushållsuppgifter.
## Starta backend ## Starta backend
@ -49,3 +49,16 @@ Frontend:
cd frontend cd frontend
pnpm test pnpm test
``` ```
## Dokumentation
Projektets aktuella arkitektur, utvecklingsprocess, övergripande beslut och
featurehistorik finns i [`docs/`](docs/):
- [arkitektur](docs/architecture.md)
- [roadmap och planerad featureordning](docs/roadmap.md)
- [utvecklingsprocess](docs/development.md)
- [arkitekturbeslut](docs/decisions/)
- [implementerade features](docs/features/)
Dokumentationen ska uppdateras tillsammans med implementation och tester.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

205
docs/architecture.md Normal file
View File

@ -0,0 +1,205 @@
# HemHubs arkitektur
Detta dokument beskriver den arkitektur som kan verifieras i repositoryts kod,
tester och konfiguration. Historiska implementationssteg finns under
[`features/`](features/) och övergripande beslut under
[`decisions/`](decisions/).
## Aktuell implementation
### Monorepo
HemHub ligger i ett Git-repository med två separata applikationer:
```text
hemhub/
├── backend/
├── frontend/
└── docs/
```
Applikationerna har egna byggverktyg och beroenden. De delar inte källkod eller
byggprocess.
### Frontend
Frontend finns i `frontend/` och använder React 19, TypeScript, Vite och pnpm.
Den ansvarar för:
- hämtning och presentation av användare och uppgifter;
- lokalt val av aktiv användare;
- formulär för att skapa användare och uppgifter;
- val och visning av ansvarig användare på uppgifter;
- serverbekräftade statusändringar genom knappar på uppgiftskorten;
- klientnära validering och begripliga felmeddelanden;
- uppgiftsbrädan med kolumnerna Väntande, Pågående och Klart.
Tillståndet hanteras lokalt i React-komponenter. Ingen router eller separat
global state-lösning används.
### Backend
Backend finns i `backend/` och använder Java 21, Spring Boot 4.1.0, Maven,
Spring Web, Spring Data JPA och Flyway. Maven Wrapper ingår i repositoryt.
Backend ansvarar för API, slutlig validering, skapande av UUID och tidsstämplar,
persistens samt sortering av returnerade användare och uppgifter.
### Kommunikation
Alla applikationsendpoints ligger under `/api`. Frontend använder enbart
relativa adresser, exempelvis `/api/users` och `/api/tasks`.
Vid lokal utveckling kör Vite normalt på port 5173 och proxar `/api` till
`http://localhost:8080`, där Spring Boot körs. Ingen generell
CORS-konfiguration finns i backend. Webbläsaren anropar därmed Vites origin,
och utvecklingsservern vidarebefordrar API-anropen.
Aktuella endpoints:
- `GET /api/health`
- `GET /api/users`
- `POST /api/users`
- `GET /api/tasks`
- `POST /api/tasks`
- `PUT /api/tasks/{taskId}/assignee`
- `PUT /api/tasks/{taskId}/status`
### Databas och migreringar
Lokal körning använder en H2-databas i minnet. Databasen finns under
backendprocessens livstid och lokal utvecklingsdata återställs när backend
startas om. Automatiska backendtester använder en separat H2-databas i minnet.
Båda anslutningarna använder H2:s `MODE=PostgreSQL`,
`DATABASE_TO_LOWER=TRUE` och `DEFAULT_NULL_ORDERING=HIGH`. Det är en verifierbar
kompatibilitetsinställning, inte samma sak som att applikationen har verifierats
mot PostgreSQL.
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.
### Domänmodell
#### Användare
En användare lagras i tabellen `app_user` med:
- `id`: UUID;
- `name`: visningsnamn, högst 50 tecken;
- `normalized_name`: trimmat namn i gemener, internt och unikt;
- `created_at`: en `Instant`, lagrad som `TIMESTAMP WITH TIME ZONE`.
`normalized_name` exponeras inte via API. Användare returneras alfabetiskt efter
visningsnamn med deterministiska sekundära jämförelser.
#### Uppgift
En uppgift lagras i tabellen `task` med:
- `id`: UUID;
- `title`: obligatorisk titel, högst 100 tecken;
- `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. En uppgift kan vara otilldelad eller referera till exakt en
ansvarig användare. Relationen hämtas tillsammans med uppgifterna när de listas,
så API-responsen kan innehålla ansvarigs `id` och `name` utan separata
frontend-anrop. Alla aktiva användare ser samma uppgiftslista.
Ansvarig är valfri vid skapande. Tilldelnings-API:t kan tilldela eller byta
ansvarig i samtliga statusar. Ansvarig kan tas bort i `WAITING` och `COMPLETED`,
men inte i `IN_PROGRESS`. Tilldelning ändrar aldrig uppgiftens status.
Alla direkta statusövergångar är tillåtna och samma målstatus är idempotent.
`IN_PROGRESS` kräver en ansvarig. När en otilldelad uppgift påbörjas skickar
frontend aktiv användares id, och backend tilldelar användaren och ändrar status
i samma transaktion. En befintlig ansvarig byts aldrig av statusoperationen.
### Aktiv användare
Användarlistan hämtas från backend. Frontend lagrar endast den valda
användarens UUID i webbläsarens `localStorage` under nyckeln
`hemhub.activeUserId`.
Vid start verifieras det lagrade id:t mot backendens aktuella användarlista. Ett
giltigt val återanvänds i samma browser. Ett ogiltigt val tas bort. Valet är
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`,
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. Status- och tilldelningsfel visas lokalt på berört kort;
kortet uppdateras först med backendens bekräftade respons.
### Teststrategi
Backend har JUnit 5-tester:
- ett fristående MockMvc-test för health-endpointen;
- Spring Boot-integrationstester via MockMvc mot H2 in-memory för användar- och
uppgifts-API.
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.
### Produktionsdeployment
Ingen produktionsdeployment är implementerad i repositoryt. Det finns inga
Dockerfiler, pipelinefiler eller produktionsspecifika Nginx-, Watchtower- eller
databaskonfigurationer. H2 används både lokalt och i automatiska tester; någon
PostgreSQL-konfiguration finns ännu inte.
## Beslutad planerad riktning
Repositoryt anger att affärsregler även framöver ska säkerställas i backend och
att större arkitekturella beslut ska diskuteras innan de införs.
Följande produktionsriktning är beslutad men ännu inte implementerad:
- PostgreSQL ska användas som produktionsdatabas.
- Frontend och backend ska paketeras som separata Docker-images.
- Källkoden ligger i Gitea.
- Drone ska bygga och publicera images till ett privat registry.
- Watchtower ska uppdatera de körande tjänsterna när nya images publiceras.
- Nginx kan användas som reverse proxy framför tjänsterna.
- Produktionsmiljön ska köras på Ubuntu-servern Biff.
Den planerade riktningen beskrivs även i
[`005-production-deployment-direction.md`](decisions/005-production-deployment-direction.md).
Punkterna ovan beskriver målbilden och ska inte tolkas som att motsvarande
konfiguration redan finns eller har verifierats.
## Fortfarande öppna detaljer
Följande har inte fastställts i dokumentationen och ska beslutas i samband med
att produktionslösningen implementeras:
- exakt containerstruktur och tjänsteindelning;
- image-namn och taggningsstrategi;
- produktions-URL;
- hantering och distribution av secrets;
- exakt Nginx-konfiguration;
- exakt Drone-, registry-, Watchtower- och deploymentkonfiguration.
Miljöspecifika adresser, credentials och secrets ska inte lagras i dessa
arkitekturdokument.

View File

@ -0,0 +1,28 @@
# 001 Monorepo med separata applikationer
## Status
Accepterat
## Datum
2026-07-23
## Sammanhang
HemHub behöver en webbläsarklient och ett server-API. Båda delarna utvecklas
inkrementellt och behöver kunna versionshanteras och dokumenteras tillsammans.
## Beslut
Frontend och backend ligger i samma Git-repository, i katalogerna `frontend/`
respektive `backend/`. De är separata applikationer med egna byggverktyg,
beroenden och startkommandon.
## Konsekvenser
- En feature kan ändra frontend, backend, tester och dokumentation atomärt.
- En gemensam historik beskriver hela systemet.
- Applikationerna kan startas och testas oberoende.
- Repositoryt har ingen gemensam rotbyggprocess; relevanta kommandon körs i
respektive applikationskatalog.

View File

@ -0,0 +1,32 @@
# 002 Relativa API-adresser och lokal utvecklingsproxy
## Status
Accepterat
## Datum
2026-07-23
## Sammanhang
Frontend körs lokalt med Vite på port 5173 och backend med Spring Boot på port
8080. Frontend behöver nå API:t utan miljöspecifika, hårdkodade backendadresser
i applikationskoden.
## Beslut
Frontend använder relativa API-adresser under `/api`. Vites utvecklingsserver
proxar `/api` till `http://localhost:8080`.
Ingen generell CORS-konfiguration införs i backend så länge webbläsaren anropar
Vites origin och Vite vidarebefordrar anropet.
## Konsekvenser
- Frontendkoden innehåller inte en lokal fullständig backend-URL.
- Lokal utveckling kräver att backend är tillgänglig på port 8080 för
API-anrop via proxyn.
- En separat CORS-policy behöver inte underhållas för nuvarande lokala flöde.
- En framtida driftlösning måste ge `/api` en motsvarande same-origin-väg eller
medföra ett nytt dokumenterat beslut.

View File

@ -0,0 +1,34 @@
# 003 Centrala användare och lokalt val av aktiv användare
## Status
Accepterat
## Datum
2026-07-24
## Sammanhang
HemHub behöver veta vem som använder gränssnittet, men har ännu ingen
autentisering. Användarlistan ska vara gemensam medan själva valet kan vara
lokalt för den aktuella browsern.
## Beslut
Användare lagras centralt via backend och hämtas från `/api/users`. Frontend
lagrar endast vald användares UUID i `localStorage` med nyckeln
`hemhub.activeUserId`.
Vid appstart jämförs det lokala id:t med backendens användarlista. Ett giltigt id
återanvänds och ett ogiltigt id tas bort. `Logga ut` tar bort nyckeln och visar
användarvalet igen.
## Konsekvenser
- Samma browser kan återanvända sitt senaste giltiga användarval.
- En annan browser eller en rensad browserlagring måste välja användare igen.
- Endast id lagras lokalt; aktuellt namn kommer från backendens lista.
- Valet synkroniseras inte mellan browsers eller enheter.
- Lösningen identifierar en användare i gränssnittet men ger ingen säker
autentisering, session eller behörighetskontroll.

View File

@ -0,0 +1,30 @@
# 004 Kortlivade feature-branches
## Status
Accepterat
## Sammanhang
HemHub utvecklas inkrementellt med avgränsade ändringar. Historiska
feature-branches ska kunna raderas efter merge utan att projektets motiv och
aktuella läge försvinner.
## Beslut
Varje feature eller avgränsad ändring utvecklas på en kortlivad branch som
skapas från uppdaterad `main`. Kod, tester och relevant dokumentation ingår i
samma ändring.
Commit och push görs först efter uttrycklig instruktion. Merge sker efter
verifiering, och `main` ska representera verifierad kod. Därefter kan branchen
raderas.
## Konsekvenser
- Pågående arbete isoleras från `main`.
- En feature kan granskas och verifieras som en sammanhållen ändring.
- Dokumentationen måste uppdateras före merge så att raderade branches inte
behövs för att förstå projektet.
- Övergripande beslut bevaras i `docs/decisions/` och faktisk featurehistorik i
`docs/features/`.

View File

@ -0,0 +1,52 @@
# 005 Riktning för produktionsdeployment
## Status
Accepterat som planerad riktning, ännu inte implementerat
## Sammanhang
HemHub använder i nuläget H2 för lokal utveckling och tester. Repositoryt saknar
fortfarande container-, pipeline- och produktionskonfiguration, men den
övergripande målbilden för byggande och drift behöver vara dokumenterad innan
den implementeras.
Källkoden ligger i Gitea och den planerade produktionsmiljön är Ubuntu-servern
Biff.
## Beslut
- PostgreSQL ska användas som produktionsdatabas.
- Frontend och backend ska paketeras som Docker-images.
- Drone ska bygga och publicera images till ett privat registry.
- Watchtower ska uppdatera tjänsterna när nya images publiceras.
- Nginx kan användas som reverse proxy.
Detta ADR fastställer komponenterna och ansvarsfördelningen på övergripande
nivå. Det inför inte någon konfiguration och innebär inte att lösningen redan
har driftverifierats.
## Konsekvenser
- Kommande produktionsarbete behöver införa och verifiera PostgreSQL-stöd,
Dockerpaketering och en Drone-baserad leveranskedja.
- Images behöver kunna publiceras till ett privat registry som Biff kan nå.
- Uppdateringsflödet behöver utformas så att Watchtower kan hämta och starta nya
images på ett kontrollerat sätt.
- Nginx är ett möjligt reverse proxy-lager, inte en fastställd detaljkonfiguration.
- Lokal utveckling och automatiska tester fortsätter använda H2 tills ett
separat beslut eller en feature ändrar detta.
## Öppna detaljer
Följande beslutas först när produktionslösningen implementeras:
- exakt containerstruktur;
- image-namn och taggningsstrategi;
- produktions-URL;
- secrets och hur de tillförs till pipeline och tjänster;
- exakt Nginx-konfiguration;
- exakt Drone-, registry-, Watchtower- och deploymentkonfiguration.
IP-adresser, credentials och andra miljöspecifika känsliga värden ska inte
dokumenteras här.

63
docs/development.md Normal file
View File

@ -0,0 +1,63 @@
# Utvecklingsprocess
Repositoryt är projektets facit. ChatGPT- eller Codex-dialoger kan användas som
arbetsyta, men implementation, tester och dokumentation ska tillsammans göra
projektets läge begripligt utan tidigare dialoger eller raderade branches.
## Arbetssätt
- Använd en kortlivad branch per feature eller annan avgränsad ändring.
- Skapa branchen från en uppdaterad `main`.
- En feature per ChatGPT-dialog är en praktisk arbetsform, inte en
dokumentationskälla.
- Välj nästa feature från [`roadmap.md`](roadmap.md).
- Uppdatera roadmapen innan en feature delas, flyttas, ersätts eller läggs till.
En dialog får inte skapa en parallell featureplan som saknas i repositoryt.
- Skapa eller uppdatera feature-dokumentet inom samma feature.
- Ge Codex en tydligt avgränsad specifikation.
- Implementera endast uttryckliga krav och undvik spekulativ funktionalitet.
- Kör relevanta tester före commit och gör manuell verifiering när beteendet
motiverar det.
- Commit och push sker först efter uttrycklig instruktion.
- Merge sker först när ändringen har verifierats.
- Uppdatera arkitektur- och beslutsdokument när övergripande beslut förändras.
`main` ska innehålla verifierad kod. När en feature har mergats ska dess branch
kunna raderas utan att projektkunskap går förlorad.
## Rekommenderad featureprocess
1. Uppdatera `main`.
2. Välj nästa feature från roadmapen och dokumentera först eventuell ändring av
planen.
3. Skapa en avgränsad branch.
4. Skapa eller uppdatera feature-dokumentet.
5. Implementera specifikationen.
6. Kör relevanta automatiska tester och bygge.
7. Gör manuell verifiering där det är relevant.
8. Uppdatera dokumentationen så att den beskriver den faktiska lösningen.
9. Commit och push efter uttrycklig instruktion.
10. Merge efter verifiering.
11. Radera den mergade branchen.
## Verifiering före merge
För nuvarande projekt bör verifieringen normalt omfatta:
```bash
cd backend
./mvnw test
```
```bash
cd frontend
pnpm test
pnpm build
```
Kör även `git diff --check` och granska `git status --short`. Manuell lokal
verifiering av berörda flöden kompletterar, men ersätter inte, automatiska
tester.
Om ett befintligt test misslyckas av ett skäl utanför ändringens omfattning ska
det rapporteras; produktionskod ska inte ändras enbart för att dölja felet.

View File

@ -0,0 +1,76 @@
# 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

@ -0,0 +1,99 @@
# 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

@ -0,0 +1,108 @@
# 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

@ -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

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

439
docs/roadmap.md Normal file
View File

@ -0,0 +1,439 @@
# HemHub roadmap
## Syfte
Roadmapen är HemHubs styrande plan för val och ordning av kommande features. Den
utgår från den faktiska implementationen efter Feature 2 och från beslut som
dokumenterats i arkitektur- och beslutsdokumenten.
Planen är ändringsbar. Ordningen uttrycker nuvarande prioritering och beroenden,
inte ett löfte om att alla features måste genomföras oförändrade.
## Regler för användning
- Nästa feature ska väljas från denna roadmap.
- Feature 02 behåller sina nummer och sin historiska betydelse.
- Ändra roadmapen innan en feature delas, flyttas, ersätts eller läggs till.
- Dokumentera motiv och beroendeförändringar innan utveckling påbörjas.
- En ChatGPT- eller Codex-dialog får inte skapa en alternativ featureplan utan
att roadmapen först uppdateras i repositoryt.
- Håll varje feature tillräckligt liten för separat implementation och
verifiering.
- Beskriv mål och affärsregler här; bindande implementationsdetaljer hör till
feature-specifikationen och relevanta beslutsdokument.
- En designfeature producerar dokument och beslut, inte produktionskod, om inget
annat uttryckligen beslutas.
Följande statusvärden används:
- **Klar** implementerad, verifierad och mergad.
- **Planerad** ingår i nuvarande ordning men har inte påbörjats.
- **Pågående** utveckling pågår i en aktiv feature.
- **Villkorad** genomförs endast om det angivna villkoret uppfylls.
- **Ersatt** har ersatts av en dokumenterad annan feature eller plan.
## Nuvarande läge
Feature 04 är klara. Den aktuella applikationen har:
- ett monorepo med separat React/Vite-frontend och Spring Boot-backend;
- centralt lagrade användare och ett lokalt browserval av aktiv användare;
- gemensamma uppgifter med titel, valfri beskrivning, status och poäng;
- skapande och listning av uppgifter;
- valfri tilldelning av högst en ansvarig användare per uppgift;
- tilldelning, byte och borttagning av ansvarig för väntande uppgifter;
- en bräda med Väntande, Pågående och Klart;
- nya uppgifter som alltid skapas med status `WAITING`.
Tilldelning och status är separata egenskaper; tilldelningsflödet ändrar inte
uppgiftens status. Statusändring och regeln att `IN_PROGRESS` kräver ansvarig
är under utveckling. Det finns ännu ingen drag-and-drop, redigering, radering,
deadline eller återkommande uppgift.
Nuvarande användarval är inte autentisering.
**Feature 5 Statusändring och statusregler är pågående.**
## Featureöversikt
| Feature och namn | Status | Beroenden | Huvudsakligt resultat |
| --- | --- | --- | --- |
| 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 | Klar | 2 | Poäng på uppgifter |
| 4 Tilldelning | Klar | 12 | Valfri ansvarig användare |
| 5 Statusändring | Pågående | 4 | Backendstyrda statusövergångar |
| 6 Drag-and-drop | Planerad | 5 | Kortflytt via status-API |
| 7 Radera uppgift | Planerad | 2 | Bekräftad radering |
| 8 Redigera uppgift | Planerad | 3 | Titel, beskrivning och poäng |
| 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 |
| 12 Återkommande uppgifter | Planerad | 5, 9, 11 | Implementerad återkommandemodell |
| 13 Poänghistorik och summering | Planerad | 35, 12 | Slutförandehistorik och summering |
| 14 PostgreSQL | Planerad | 013 | Verifierad produktionsdatabas |
| 15 Dockerpaketering | Planerad | 14 | Images och produktionslik lokal körning |
| 16 Pipeline och deployment | Planerad | 15 | Bygge, publicering och drift på Biff |
| 17 Autentisering | Villkorad | 16, extern åtkomst | Säker internetexponering |
## Genomförda features
### Feature 0 Projektgrund
**Status:** Klar
Feature 0 etablerade monorepot, React/Vite-frontend, Spring Boot-backend,
health-endpoint, lokal Vite-proxy och grundtester. Den faktiska lösningen
beskrivs i
[`000-project-foundation.md`](features/000-project-foundation.md).
### Feature 1 Användarval
**Status:** Klar
Feature 1 införde skapande och listning av användare, val av aktiv användare och
lokal lagring av användarens id. Lösningen är ett browserlokalt användarval,
inte riktig autentisering. Den faktiska lösningen beskrivs i
[`001-user-selection.md`](features/001-user-selection.md).
### Feature 2 Skapa uppgifter
**Status:** Klar
Feature 2 införde en grundläggande uppgiftsmodell, API för att lista och skapa
uppgifter samt en bräda med tre statuskolumner. Uppgifter har titel, valfri
beskrivning och status; nya uppgifter skapas som `WAITING`. Den faktiska
lösningen beskrivs i
[`002-task-creation.md`](features/002-task-creation.md).
## Fas 1 Komplettera den centrala uppgiftsmodellen
Fasen lägger till den domändata och de backendregler som behövs innan mer
interaktiv brädhantering införs.
### Feature 3 Uppgiftspoäng
**Status:** Klar
**Beroenden:** Feature 2
**Mål:**
- lägga till obligatoriska poäng på uppgifter;
- välja och dokumentera poängskala;
- ange poäng vid skapande;
- visa poäng på uppgiftskort;
- migrera befintliga uppgifter kontrollerat.
Feature 3 ligger först eftersom poäng blir ett centralt uppgiftsfält som senare
ska kunna redigeras och historikföras.
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;
databasen har inget permanent defaultvärde.
### Feature 4 Tilldelning av uppgifter
**Status:** Klar
**Beroenden:** Feature 1 och Feature 2
**Mål:**
- lägga till en valfri ansvarig användare;
- tillåta ansvarig vid skapande;
- visa ansvarig på uppgiftskort;
- kunna ändra ansvarig på en befintlig uppgift.
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:** Pågående
**Beroenden:** Feature 4
**Mål:**
- införa backend-API för statusändring;
- stödja `WAITING`, `IN_PROGRESS` och `COMPLETED`;
- säkerställa statusregler i backend;
- ge ett enkelt UI för statusändring före drag-and-drop.
`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. Feature 5 återanvänder Feature 4:s
tilldelningsmodell och särskilda API för ansvarig; statusändring sker i ett
separat statusflöde.
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
**Beroenden:** Feature 5
**Mål:**
- flytta uppgiftskort mellan statuskolumner;
- använda status-API:t från Feature 5;
- hantera serverfel och återställning av UI;
- hantera en otilldelad uppgift som flyttas till Pågående.
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.
## Fas 2 Hantering av uppgifter
Fasen kompletterar livscykeln för enskilda uppgifter efter att den centrala
modellen och statusreglerna finns.
### Feature 7 Radera uppgift
**Status:** Planerad
**Beroenden:** Feature 2
**Mål:**
- införa backend-API för radering;
- radera en uppgift från brädan;
- kräva bekräftelse före radering.
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 8 Redigera uppgift
**Status:** Planerad
**Beroenden:** Feature 3
**Mål:**
- ändra titel;
- ändra beskrivning;
- ändra poäng.
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.
### Feature 9 Deadline
**Status:** Planerad
**Beroenden:** Feature 2
**Mål:**
- lägga till en valfri deadline;
- stödja beslutad representation av datum och eventuell tid;
- visa deadline på uppgiftskort;
- markera försenade uppgifter.
Deadline införs före återkommande uppgifter eftersom framtida förekomster måste
kunna ärva eller beräkna deadlines.
**Öppna frågor:**
- datum utan tid eller datum och tid;
- tidszonshantering;
- definition av en försenad uppgift.
### Feature 10 Sökning och filtrering
**Status:** Planerad
**Beroenden:** Feature 2; Feature 4 för ansvarigfilter; Feature 9 om
deadlinefilter ska ingå
**Mål:**
- söka på titel och beskrivning;
- filtrera på ansvarig och status;
- eventuellt filtrera på deadline.
Datamängden i ett familjehushåll är sannolikt liten. Klientbaserad sökning kan
därför vara tillräcklig initialt, men valet ska göras i feature-specifikationen.
Sökning på titel och beskrivning samt statusfiltrering kan byggas från Feature
2. Filtrering på ansvarig kräver Feature 4, och deadlinefilter kräver Feature 9
om det ska ingå.
**Öppen fråga:**
- klientbaserad eller serverbaserad sökning.
## Fas 3 Återkommande arbete och historik
Fasen kräver först ett uttryckligt modellbeslut, eftersom återkommande arbete
påverkar status, deadline, ansvarig och poäng.
### Feature 11 Design av återkommande uppgifter
**Status:** Planerad
**Typ:** Designfeature
**Beroenden:** Beslutade modeller från Feature 35 och Feature 9
**Ingen produktionskod ska implementeras i denna feature.**
**Mål:**
- besluta skillnaden mellan uppgiftsmall och konkret förekomst;
- definiera hur nästa förekomst skapas;
- definiera vad slutförande betyder;
- definiera hur en förekomst hoppas över;
- definiera hur ändringar påverkar framtida förekomster;
- definiera hur ansvarig, poäng och deadline ärvs.
Resultatet ska vara ett beslutsdokument och en avgränsad implementationsplan för
Feature 12. Designsteget ligger före implementationen för att undvika att
domänbeslut byggs in implicit.
### Feature 12 Implementera återkommande uppgifter
**Status:** Planerad
**Beroenden:** Feature 5, Feature 9 och Feature 11
**Mål:**
- implementera modellen som beslutades i Feature 11.
### Feature 13 Poänghistorik och summering
**Status:** Planerad
**Beroenden:** Feature 3, Feature 4, Feature 5 och Feature 12
**Mål:**
- registrera vem som slutförde en uppgift;
- registrera när uppgiften slutfördes;
- summera poäng per användare och period;
- visa enkel historik.
Historik ligger efter status, poäng och återkommande uppgifter eftersom
slutförandet måste vara en backendvaliderad händelse med ett definierat
poängvärde och en definierad konkret förekomst.
**Öppna frågor:**
- om tilldelad och slutförande användare kan vara olika;
- om poäng delas ut vid varje återkommande förekomst;
- hur återöppnade uppgifter påverkar historik.
## Fas 4 Produktion
Produktionsfasen realiserar den beslutade riktningen i
[`005-production-deployment-direction.md`](decisions/005-production-deployment-direction.md).
Inget i denna fas är implementerat i nuläget.
### Feature 14 PostgreSQL och produktionsdatabas
**Status:** Planerad
**Beroenden:** Föregående produktfeatures vars persistens ska produktionssättas
**Mål:**
- lägga till produktionskonfiguration för PostgreSQL;
- verifiera Flyway-migreringar mot PostgreSQL;
- införa PostgreSQL-baserade integrationstester, exempelvis med Testcontainers;
- behålla en enkel lokal utvecklingsupplevelse.
PostgreSQL införs före paketering för att databasdrivrutin, migreringar och
konfiguration ska vara verifierade innan en produktionslik stack byggs.
**Öppen fråga:**
- om lokal utveckling fortsatt ska kunna använda H2.
### Feature 15 Dockerpaketering
**Status:** Planerad
**Beroenden:** Feature 14
**Mål:**
- skapa en backend-image;
- skapa en frontend-image;
- stödja produktionslik lokal körning;
- ge same-origin `/api` via Nginx.
Paketeringen kommer före pipelinearbetet så att images kan byggas och verifieras
lokalt.
### Feature 16 Pipeline och deployment
**Status:** Planerad
**Beroenden:** Feature 15
**Mål:**
- skapa en Drone-pipeline;
- publicera images till ett privat registry;
- driftsätta på Ubuntu-servern Biff;
- uppdatera tjänster med Watchtower;
- införa nödvändig Nginx-konfiguration;
- hantera secrets utanför Git.
Exakta miljödetaljer ska beslutas inom featuren och känsliga värden ska inte
committas.
## Fas 5 Eventuell extern åtkomst
### Feature 17 Autentisering och internetexponering
**Status:** Villkorad
**Beroenden:** Beslut att exponera HemHub mot internet och en säker
produktionsgrund, normalt Feature 16
Featuren ska endast genomföras om HemHub ska göras åtkomlig från internet.
Nuvarande aktiva användarval är uttryckligen inte autentisering.
**Mål:**
- införa riktig autentisering;
- införa behörighetsregler;
- använda säker sessions- eller tokenhantering;
- konfigurera TLS och extern exponering;
- säkerhetsgranska API och deployment.
## Ö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
ansvariga för eller har slutfört uppgifter?
- Vid vilken typ av extern åtkomst krävs riktig autentisering?
- Hur ska UI-designen från befintliga skisser införas inkrementellt utan att
blanda in framtida funktionalitet?
## Ändringshistorik
- 2026-07-26: Feature 3 och Feature 4 markerades som klara efter verifiering och
merge. Feature 5 blev nästa planerade produktfeature.
- 2026-07-26: Roadmapen etablerades. Feature 02 markerades som klara, Feature
316 planerades och Feature 17 markerades som villkorad.

View File

@ -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 />)
await screen.findByText('Dammsuga')
const waiting = await screen.findByRole('region', { name: 'Väntande' }) const waiting = await screen.findByRole('region', { name: 'Väntande' })
const inProgress = screen.getByRole('region', { name: 'Pågående' }) const inProgress = screen.getByRole('region', { name: 'Pågående' })
const completed = screen.getByRole('region', { name: 'Klart' }) const completed = screen.getByRole('region', { name: 'Klart' })
expect(within(waiting).getByText('Dammsuga')).toBeInTheDocument() expect(within(waiting).getByText('Dammsuga')).toBeInTheDocument()
expect(within(waiting).getByText('Bottenvåningen')).toBeInTheDocument() expect(within(waiting).getByText('Bottenvåningen')).toBeInTheDocument()
expect(within(waiting).getByText('7 p')).toBeInTheDocument()
expect(within(inProgress).getByText('Diska')).toBeInTheDocument() expect(within(inProgress).getByText('Diska')).toBeInTheDocument()
expect(within(completed).getByText('Vattna blommor')).toBeInTheDocument() expect(within(completed).getByText('Vattna blommor')).toBeInTheDocument()
expect(screen.queryByText(/Inga uppgifter/i)).not.toBeInTheDocument() expect(screen.queryByText(/Inga uppgifter/i)).not.toBeInTheDocument()
@ -187,6 +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,21 +273,383 @@ test('en skapad uppgift visas längst ned i Väntande och modalen stängs', asyn
fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), { fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), {
target: { value: ' Köket ' }, target: { value: ' Köket ' },
}) })
fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: '7' } })
fireEvent.change(screen.getByLabelText('Tilldela'), { target: { value: users[1].id } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' })) fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' }))
await waitFor(() => await waitFor(() =>
expect(screen.queryByRole('dialog', { name: 'Skapa ny uppgift' })).not.toBeInTheDocument(), expect(screen.queryByRole('dialog', { name: 'Skapa ny uppgift' })).not.toBeInTheDocument(),
) )
const waiting = screen.getByRole('region', { name: 'Väntande' }) const waiting = await screen.findByRole('region', { name: 'Väntande' })
expect(within(waiting).getAllByRole('article').map((card) => card.textContent)).toEqual([ expect(
'DammsugaBottenvåningen', within(waiting)
'Putsa fönsterKöket', .getAllByRole('article')
]) .map((card) => within(card).getByRole('heading').textContent),
).toEqual(['Dammsuga', 'Putsa fönster'])
expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', { expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Putsa fönster', description: 'Köket' }), body: JSON.stringify({
title: 'Putsa fönster',
description: 'Köket',
points: 7,
assigneeId: users[1].id,
}),
}) })
fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' }))
expect(screen.getByLabelText('Poäng')).toHaveValue(1)
expect(screen.getByLabelText('Tilldela')).toHaveValue('')
})
test('Ingen skickas som null när en uppgift skapas', async () => {
const createdTask = {
...tasks[0],
id: '00000000-0000-0000-0000-000000000010',
title: 'Torka bordet',
}
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([]))
fetchMock.mockResolvedValueOnce(jsonResponse(createdTask, 201))
render(<App />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' }))
fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Torka bordet' } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' }))
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3))
expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Torka bordet',
description: null,
points: 1,
assigneeId: null,
}),
})
})
test('statusanrop byter inte en befintlig ansvarig', async () => {
const assignedWaiting = {
...tasks[0],
assignee: { id: users[1].id, name: users[1].name },
}
const updatedTask = { ...assignedWaiting, status: 'IN_PROGRESS' }
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([assignedWaiting]))
fetchMock.mockResolvedValueOnce(jsonResponse(updatedTask))
render(<App />)
const card = (await screen.findByText('Dammsuga')).closest('article')!
fireEvent.click(within(card).getByRole('button', { name: 'Påbörja' }))
const inProgress = screen.getByRole('region', { name: 'Pågående' })
expect(await within(inProgress).findByText('Anna')).toBeInTheDocument()
expect(within(inProgress).queryByText('Urban')).not.toBeInTheDocument()
})
test('alla statusar har redigerbar tilldelning med statusberoende alternativ', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
mockUsersAndTasks(users, tasks)
render(<App />)
const waitingCard = (await screen.findByText('Dammsuga')).closest('article')
const inProgressCard = screen.getByText('Diska').closest('article')
const completedCard = screen.getByText('Vattna blommor').closest('article')
expect(waitingCard).not.toBeNull()
expect(inProgressCard).not.toBeNull()
expect(completedCard).not.toBeNull()
expect(within(waitingCard!).getByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
.toHaveTextContent('Ta uppgift')
expect(within(inProgressCard!).getByText('Anna')).toBeInTheDocument()
expect(within(completedCard!).getByText('Otilldelad')).toBeInTheDocument()
fireEvent.click(
within(inProgressCard!).getByRole('button', { name: 'Ändra ansvarig för Diska' }),
)
expect(
within(inProgressCard!).queryByRole('option', { name: 'Ingen' }),
).not.toBeInTheDocument()
fireEvent.click(
within(completedCard!).getByRole('button', {
name: 'Ändra ansvarig för Vattna blommor',
}),
)
expect(within(completedCard!).getByRole('option', { name: 'Ingen' })).toBeInTheDocument()
})
test('visar rätt statusknappar för varje kolumn', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
mockUsersAndTasks(users, tasks)
render(<App />)
const waitingCard = (await screen.findByText('Dammsuga')).closest('article')!
const inProgressCard = screen.getByText('Diska').closest('article')!
const completedCard = screen.getByText('Vattna blommor').closest('article')!
expect(within(waitingCard).getByRole('button', { name: 'Påbörja' })).toBeInTheDocument()
expect(within(waitingCard).getByRole('button', { name: 'Markera klar' })).toBeInTheDocument()
expect(within(inProgressCard).getByRole('button', { name: 'Till Väntande' }))
.toBeInTheDocument()
expect(within(inProgressCard).getByRole('button', { name: 'Markera klar' }))
.toBeInTheDocument()
expect(within(completedCard).getByRole('button', { name: 'Till Väntande' }))
.toBeInTheDocument()
expect(within(completedCard).getByRole('button', { name: 'Påbörja igen' }))
.toBeInTheDocument()
})
test.each([
{ task: tasks[0], button: 'Påbörja', target: 'IN_PROGRESS' },
{ task: tasks[0], button: 'Markera klar', target: 'COMPLETED' },
{ task: tasks[1], button: 'Till Väntande', target: 'WAITING' },
{ task: tasks[1], button: 'Markera klar', target: 'COMPLETED' },
{ task: tasks[2], button: 'Till Väntande', target: 'WAITING' },
{ task: tasks[2], button: 'Påbörja igen', target: 'IN_PROGRESS' },
])('$button skickar status $target', async ({ task, button, target }) => {
const updatedTask = {
...task,
status: target,
assignee:
target === 'IN_PROGRESS' && !task.assignee
? { id: users[0].id, name: users[0].name }
: task.assignee,
}
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([task]))
fetchMock.mockResolvedValueOnce(jsonResponse(updatedTask))
render(<App />)
const card = (await screen.findByText(task.title)).closest('article')!
fireEvent.click(within(card).getByRole('button', { name: button }))
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3))
expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${task.id}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: target,
...(target === 'IN_PROGRESS' ? { activeUserId: users[0].id } : {}),
}),
})
})
test('status uppdateras först efter serversvar och låser endast berört kort', async () => {
const otherTask = {
...tasks[0],
id: '00000000-0000-0000-0000-000000000010',
title: 'Putsa fönster',
}
const updatedTask = {
...tasks[0],
status: 'IN_PROGRESS',
assignee: { id: users[0].id, name: users[0].name },
}
let resolveStatus!: (response: Response) => void
const statusResponse = new Promise<Response>((resolve) => {
resolveStatus = resolve
})
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0], otherTask]))
fetchMock.mockReturnValueOnce(statusResponse)
render(<App />)
const waiting = await screen.findByRole('region', { name: 'Väntande' })
const card = (await within(waiting).findByText('Dammsuga')).closest('article')!
const otherCard = within(waiting).getByText('Putsa fönster').closest('article')!
const startButton = within(card).getByRole('button', { name: 'Påbörja' })
fireEvent.click(startButton)
fireEvent.click(startButton)
expect(within(waiting).getByText('Dammsuga')).toBeInTheDocument()
expect(startButton).toBeDisabled()
expect(within(card).getByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
.toBeDisabled()
expect(within(otherCard).getByRole('button', { name: 'Påbörja' })).toBeEnabled()
expect(fetchMock).toHaveBeenCalledTimes(3)
resolveStatus(jsonResponse(updatedTask))
const inProgress = screen.getByRole('region', { name: 'Pågående' })
expect(await within(inProgress).findByText('Dammsuga')).toBeInTheDocument()
expect(within(inProgress).getByText('Urban')).toBeInTheDocument()
})
test('statusfel behåller tidigare status och ansvarig och visas på kortet', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]]))
fetchMock.mockResolvedValueOnce(
jsonResponse(
{
code: 'TASK_REQUIRES_ASSIGNEE',
message: 'En pågående uppgift måste ha en ansvarig.',
},
409,
),
)
render(<App />)
const waiting = await screen.findByRole('region', { name: 'Väntande' })
const card = (await within(waiting).findByText('Dammsuga')).closest('article')!
fireEvent.click(within(card).getByRole('button', { name: 'Påbörja' }))
expect(await within(card).findByRole('alert')).toHaveTextContent(
'En pågående uppgift måste ha en ansvarig.',
)
expect(within(waiting).getByText('Dammsuga')).toBeInTheDocument()
expect(within(card).getByText('Ta uppgift')).toBeInTheDocument()
})
test('ansvarig kan bytas i Pågående och tas bort i Klart', async () => {
const changedInProgress = { ...tasks[1], assignee: { id: users[0].id, name: users[0].name } }
const unassignedCompleted = { ...tasks[2], assignee: null }
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[1], { ...tasks[2], assignee: tasks[1].assignee }]))
fetchMock.mockResolvedValueOnce(jsonResponse(changedInProgress))
fetchMock.mockResolvedValueOnce(jsonResponse(unassignedCompleted))
render(<App />)
const inProgressCard = (await screen.findByText('Diska')).closest('article')!
fireEvent.click(
within(inProgressCard).getByRole('button', { name: 'Ändra ansvarig för Diska' }),
)
fireEvent.change(within(inProgressCard).getByRole('combobox'), {
target: { value: users[0].id },
})
await waitFor(() =>
expect(within(inProgressCard).getByRole('button', { name: 'Ändra ansvarig för Diska' }))
.toHaveTextContent('Urban'),
)
const completedCard = screen.getByText('Vattna blommor').closest('article')!
fireEvent.click(
within(completedCard).getByRole('button', {
name: 'Ändra ansvarig för Vattna blommor',
}),
)
fireEvent.change(within(completedCard).getByRole('combobox'), { target: { value: '' } })
await waitFor(() =>
expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[2].id}/assignee`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assigneeId: null }),
}),
)
})
test('val av ansvarig anropar endpointen och uppdaterar kortet efter svar', async () => {
const updatedTask = { ...tasks[0], assignee: { id: users[1].id, name: users[1].name } }
let resolveAssignment!: (response: Response) => void
const assignmentResponse = new Promise<Response>((resolve) => {
resolveAssignment = resolve
})
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]]))
fetchMock.mockReturnValueOnce(assignmentResponse)
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
const select = screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' })
fireEvent.change(select, { target: { value: users[1].id } })
expect(select).toBeDisabled()
expect(within(select.closest('article')!).getByRole('button', { name: 'Påbörja' }))
.toBeDisabled()
expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/assignee`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assigneeId: users[1].id }),
})
resolveAssignment(jsonResponse(updatedTask))
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
.toHaveTextContent('Anna'),
)
expect(screen.queryByRole('combobox', { name: 'Ansvarig för Dammsuga' })).not.toBeInTheDocument()
})
test('val av Ingen av-tilldelar en väntande uppgift', async () => {
const assignedTask = { ...tasks[0], assignee: { id: users[1].id, name: users[1].name } }
const unassignedTask = { ...assignedTask, assignee: null }
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([assignedTask]))
fetchMock.mockResolvedValueOnce(jsonResponse(unassignedTask))
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
fireEvent.change(screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' }), {
target: { value: '' },
})
await screen.findByText('Ta uppgift')
expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/assignee`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assigneeId: null }),
})
})
test('misslyckad tilldelning behåller ansvarig och visar fel', async () => {
const assignedTask = { ...tasks[0], assignee: { id: users[1].id, name: users[1].name } }
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([assignedTask]))
fetchMock.mockResolvedValueOnce(
jsonResponse({ code: 'USER_NOT_FOUND', message: 'Användaren finns inte.' }, 404),
)
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Ändra ansvarig för Dammsuga' }))
fireEvent.change(screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' }), {
target: { value: users[0].id },
})
expect(await screen.findByRole('alert')).toHaveTextContent('Användaren finns inte.')
expect(screen.getByRole('combobox', { name: 'Ansvarig för Dammsuga' })).toHaveValue(users[1].id)
})
test.each([
{ värde: '', beskrivning: 'tomt' },
{ värde: '0', beskrivning: 'under 1' },
{ värde: '100', beskrivning: 'över 99' },
{ värde: '1.5', beskrivning: 'decimaltal' },
])('ogiltigt poängvärde ($beskrivning) blockerar submit', async ({ värde }) => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = mockUsersAndTasks(users, [])
render(<App />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' }))
fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Dammsuga' } })
fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: värde } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'Poäng måste vara ett heltal mellan 1 och 99.',
)
expect(fetchMock).toHaveBeenCalledTimes(2)
}) })
test('formulärdata bevaras när skapande av uppgift misslyckas', async () => { test('formulärdata bevaras när skapande av uppgift misslyckas', async () => {
@ -285,14 +667,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,14 @@ function App() {
} }
if (activeUser) { if (activeUser) {
return <TaskBoard activeUserName={activeUser.name} onLogOut={logOut} /> return (
<TaskBoard
activeUserId={activeUser.id}
activeUserName={activeUser.name}
users={users}
onLogOut={logOut}
/>
)
} }
if (showCreateUser) { if (showCreateUser) {

View File

@ -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
} }
@ -15,7 +24,9 @@ type ApiError = {
} }
type TaskBoardProps = { type TaskBoardProps = {
activeUserId: string
activeUserName: string activeUserName: string
users: UserSummary[]
onLogOut: () => void onLogOut: () => void
} }
@ -25,10 +36,14 @@ const columns: { status: TaskStatus; title: string }[] = [
{ status: 'COMPLETED', title: 'Klart' }, { status: 'COMPLETED', title: 'Klart' },
] ]
function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) { function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardProps) {
const [tasks, setTasks] = useState<Task[]>([]) const [tasks, setTasks] = useState<Task[]>([])
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading') const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
const [showCreateTask, setShowCreateTask] = useState(false) const [showCreateTask, setShowCreateTask] = useState(false)
const [editingAssigneeTaskId, setEditingAssigneeTaskId] = useState<string | null>(null)
const [pendingTaskIds, setPendingTaskIds] = useState<Set<string>>(new Set())
const [taskErrors, setTaskErrors] = useState<Record<string, string>>({})
const pendingTaskIdsRef = useRef(new Set<string>())
const loadTasks = async () => { const loadTasks = async () => {
setLoadState('loading') setLoadState('loading')
@ -51,6 +66,98 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
void loadTasks() void loadTasks()
}, []) }, [])
const beginTaskRequest = (taskId: string) => {
if (pendingTaskIdsRef.current.has(taskId)) {
return false
}
pendingTaskIdsRef.current.add(taskId)
setPendingTaskIds(new Set(pendingTaskIdsRef.current))
setTaskErrors((current) => ({ ...current, [taskId]: '' }))
return true
}
const finishTaskRequest = (taskId: string) => {
pendingTaskIdsRef.current.delete(taskId)
setPendingTaskIds(new Set(pendingTaskIdsRef.current))
}
const replaceTask = (updatedTask: Task) => {
setTasks((current) =>
current.map((task) => (task.id === updatedTask.id ? updatedTask : task)),
)
}
const updateAssignee = async (task: Task, assigneeId: string) => {
if (!beginTaskRequest(task.id)) {
return
}
try {
const response = await fetch(`/api/tasks/${task.id}/assignee`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assigneeId: assigneeId || null }),
})
if (!response.ok) {
const apiError = (await response.json().catch(() => ({}))) as ApiError
setTaskErrors((current) => ({
...current,
[task.id]: apiError.message ?? 'Det gick inte att ändra ansvarig. Försök igen.',
}))
return
}
const updatedTask = (await response.json()) as Task
replaceTask(updatedTask)
setEditingAssigneeTaskId(null)
} catch {
setTaskErrors((current) => ({
...current,
[task.id]: 'Det gick inte att ändra ansvarig. Försök igen.',
}))
} finally {
finishTaskRequest(task.id)
}
}
const updateStatus = async (task: Task, status: TaskStatus) => {
if (!beginTaskRequest(task.id)) {
return
}
try {
const response = await fetch(`/api/tasks/${task.id}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status,
...(status === 'IN_PROGRESS' ? { activeUserId } : {}),
}),
})
if (!response.ok) {
const apiError = (await response.json().catch(() => ({}))) as ApiError
setTaskErrors((current) => ({
...current,
[task.id]: apiError.message ?? 'Det gick inte att ändra status. Försök igen.',
}))
return
}
replaceTask((await response.json()) as Task)
setEditingAssigneeTaskId(null)
} catch {
setTaskErrors((current) => ({
...current,
[task.id]: 'Det gick inte att ändra status. Försök igen.',
}))
} finally {
finishTaskRequest(task.id)
}
}
return ( return (
<main className="task-app"> <main className="task-app">
<header className="app-header"> <header className="app-header">
@ -90,12 +197,37 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
<div className="task-list"> <div className="task-list">
{tasks {tasks
.filter((task) => task.status === column.status) .filter((task) => task.status === column.status)
.map((task) => ( .map((task) => {
<article className="task-card" key={task.id}> const pending = pendingTaskIds.has(task.id)
<h3>{task.title}</h3>
{task.description && <p>{task.description}</p>} return (
</article> <article className="task-card" key={task.id}>
))} <div className="task-card-header">
<h3>{task.title}</h3>
<span className="points-badge">{task.points} p</span>
</div>
{task.description && <p>{task.description}</p>}
<AssigneeControl
task={task}
users={users}
editing={editingAssigneeTaskId === task.id}
pending={pending}
onEdit={() => setEditingAssigneeTaskId(task.id)}
onChange={(assigneeId) => void updateAssignee(task, assigneeId)}
/>
<TaskStatusControls
task={task}
disabled={pending}
onChange={(status) => void updateStatus(task, status)}
/>
{taskErrors[task.id] && (
<p className="task-error error" role="alert">
{taskErrors[task.id]}
</p>
)}
</article>
)
})}
</div> </div>
</section> </section>
))} ))}
@ -103,6 +235,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 +247,134 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
) )
} }
type AssigneeControlProps = {
task: Task
users: UserSummary[]
editing: boolean
pending: boolean
onEdit: () => void
onChange: (assigneeId: string) => void
}
function UserIcon() {
return (
<svg
className="user-icon"
viewBox="0 0 24 24"
width="18"
height="18"
aria-hidden="true"
>
<circle cx="12" cy="8" r="3.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path
d="M5 20c.5-4 3-6 7-6s6.5 2 7 6"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
)
}
function AssigneeControl({
task,
users,
editing,
pending,
onEdit,
onChange,
}: AssigneeControlProps) {
const displayName =
task.assignee?.name ?? (task.status === 'WAITING' ? 'Ta uppgift' : 'Otilldelad')
return (
<div className="task-assignment">
{editing ? (
<label className="assignee-select-label">
<span className="visually-hidden">Ansvarig för {task.title}</span>
<UserIcon />
<select
aria-label={`Ansvarig för ${task.title}`}
value={task.assignee?.id ?? ''}
disabled={pending}
autoFocus
onChange={(event) => onChange(event.target.value)}
>
{task.status !== 'IN_PROGRESS' && <option value="">Ingen</option>}
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</label>
) : (
<button
type="button"
className="task-assignee task-assignee-button"
disabled={pending}
onClick={onEdit}
aria-label={`Ändra ansvarig för ${task.title}`}
>
<UserIcon />
<span>{displayName}</span>
</button>
)}
</div>
)
}
type TaskStatusControlsProps = {
task: Task
disabled: boolean
onChange: (status: TaskStatus) => void
}
const statusActions: Record<TaskStatus, { label: string; target: TaskStatus }[]> = {
WAITING: [
{ label: 'Påbörja', target: 'IN_PROGRESS' },
{ label: 'Markera klar', target: 'COMPLETED' },
],
IN_PROGRESS: [
{ label: 'Till Väntande', target: 'WAITING' },
{ label: 'Markera klar', target: 'COMPLETED' },
],
COMPLETED: [
{ label: 'Till Väntande', target: 'WAITING' },
{ label: 'Påbörja igen', target: 'IN_PROGRESS' },
],
}
function TaskStatusControls({ task, disabled, onChange }: TaskStatusControlsProps) {
return (
<div className="task-status-actions" aria-label={`Ändra status för ${task.title}`}>
{statusActions[task.status].map((action) => (
<button
type="button"
className="status-button"
key={action.target}
disabled={disabled}
onClick={() => onChange(action.target)}
>
{action.label}
</button>
))}
</div>
)
}
type CreateTaskModalProps = { 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 +405,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 +417,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 +438,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 +478,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 +497,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,108 @@ 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;
}
.user-icon {
flex: 0 0 auto;
}
.assignee-select-label {
display: flex;
align-items: center;
gap: 0.4rem;
}
.assignee-select-label select {
width: auto;
min-width: 9rem;
}
.field-label-uppercase {
color: #64748b;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.task-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.points-badge {
flex: 0 0 auto;
padding: 0.2rem 0.5rem;
border-radius: 999px;
color: #1e3a8a;
background: #dbeafe;
font-size: 0.8rem;
font-weight: 700;
line-height: 1.25;
}
.task-card p { .task-card p {
margin-top: 0.5rem; margin-top: 0.5rem;
color: #475569; color: #475569;
white-space: pre-wrap; white-space: pre-wrap;
} }
.task-status-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.9rem;
}
.status-button {
padding: 0.4rem 0.65rem;
color: #1e3a8a;
background: #dbeafe;
font-size: 0.85rem;
}
.task-error {
margin-top: 0.6rem;
font-size: 0.85rem;
}
.field-help {
margin: -0.25rem 0 0;
color: #64748b;
font-size: 0.85rem;
}
.modal-backdrop { .modal-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;