29 Commits

Author SHA1 Message Date
f699f57e07 merged feature-documents 2026-07-28 14:45:40 +02:00
057758b1e9 Upload files to "docs" 2026-07-28 14:43:02 +02:00
0e42a023fc docs: close feature 8 2026-07-28 12:22:46 +02:00
28258f4c37 Merge pull request 'feat: add task editing' (#12) from feature/008-task-editing into main
Reviewed-on: #12
2026-07-28 11:19:26 +02:00
443f686c20 feat: add task editing 2026-07-28 11:18:13 +02:00
b6460a3924 docs: close feature 7 2026-07-27 22:06:08 +02:00
5df0146672 Merge pull request 'feat: add task deletion' (#11) from feature/007-task-deletion into main
Reviewed-on: #11
2026-07-27 21:27:25 +02:00
f296d15446 feat: add task deletion 2026-07-27 21:26:19 +02:00
5dea4c4027 docs: close feature 6 2026-07-27 13:21:17 +02:00
2696195e74 Merge pull request 'feat: add task drag and drop' (#10) from feature/006-task-drag-and-drop into main
Reviewed-on: #10
2026-07-27 13:18:19 +02:00
c3c64482c0 feat: add task drag and drop 2026-07-27 13:15:39 +02:00
ddd706536e Update docs/roadmap.md 2026-07-27 10:44:41 +02:00
dd145db12f Update docs/features/005-task-status.md 2026-07-27 10:39:22 +02:00
5b9e562722 Merge pull request 'feat: add task status transitions' (#9) from feature/005-task-status into main
Reviewed-on: #9
2026-07-27 00:48:44 +02:00
65a6488c0b feat: add task status transitions 2026-07-27 00:46:37 +02:00
6570aad4a2 Merge pull request 'docs: align completed feature status' (#8) from chore/005-align-feature-status into main
Reviewed-on: #8
2026-07-26 23:57:51 +02:00
85afc3d3a3 docs: align completed feature status 2026-07-26 23:56:58 +02:00
d78611f5f7 Merge pull request 'feat: add task assignment' (#7) from feature/004-task-assignment into main
Reviewed-on: #7
2026-07-26 22:50:09 +02:00
aaebe888f3 feat: add task assignment 2026-07-26 22:49:06 +02:00
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
2f7b99fb21 Merge pull request 'feat: add task creation board' (#2) from feature/002-task-creation into main
Reviewed-on: #2
2026-07-25 10:33:25 +02:00
3f152eeccc feat: add task creation board 2026-07-25 10:31:37 +02:00
49 changed files with 5980 additions and 40 deletions

View File

@ -9,4 +9,11 @@
- Affärsregler ska senare säkerställas i backend och inte enbart i frontend.
- Kod, tester och dokumentation ska hållas uppdaterade tillsammans.
- 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,8 +6,14 @@ innehåller två separata applikationer:
- en backend byggd med Java 21, Spring Boot och Maven
- en frontend byggd med React, TypeScript, Vite och pnpm
Backend använder en lokal filbaserad H2-databas i `backend/data`. Databasschemat
hanteras med Flyway. Databasfilerna är lokala och ignoreras av Git.
Backend använder en lokal H2-databas i minnet. Databasschemat hanteras med
Flyway, och lokal utvecklingsdata återställs när backend startas om.
API:t innehåller endpoints under `/api/users` för användare och `/api/tasks` för
att skapa, lista, redigera, tilldela, ändra status på och permanent radera
gemensamma hushållsuppgifter. Uppgiftskort kan flyttas mellan brädans
statuskolumner med drag-and-drop eller med de befintliga statusknapparna.
Redigering och radering genomförs först när backend har bekräftat operationen.
## Starta backend
@ -46,3 +52,16 @@ Frontend:
cd frontend
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,9 +2,16 @@ package se.rubble.hemhub.api;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import se.rubble.hemhub.task.InvalidTaskException;
import se.rubble.hemhub.task.InvalidTaskAssignmentException;
import se.rubble.hemhub.task.InvalidTaskStatusException;
import se.rubble.hemhub.task.AssigneeNotFoundException;
import se.rubble.hemhub.task.TaskNotFoundException;
import se.rubble.hemhub.task.TaskRequiresAssigneeException;
import se.rubble.hemhub.user.InvalidUserNameException;
import se.rubble.hemhub.user.UserNameAlreadyExistsException;
@ -26,5 +33,53 @@ public class ApiExceptionHandler {
"USER_NAME_ALREADY_EXISTS",
"En användare med det namnet finns redan."));
}
}
@ExceptionHandler(InvalidTaskException.class)
public ResponseEntity<ApiError> handleInvalidTask(InvalidTaskException exception) {
return ResponseEntity.badRequest()
.body(new ApiError("INVALID_TASK", exception.getMessage()));
}
@ExceptionHandler(InvalidTaskAssignmentException.class)
public ResponseEntity<ApiError> handleInvalidTaskAssignment(
InvalidTaskAssignmentException exception) {
return ResponseEntity.badRequest()
.body(new ApiError("INVALID_TASK_ASSIGNMENT", exception.getMessage()));
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiError> handleInvalidPathParameter() {
return ResponseEntity.badRequest()
.body(new ApiError(
"INVALID_TASK_ASSIGNMENT",
"Uppgifts-id måste vara ett giltigt UUID."));
}
@ExceptionHandler(TaskNotFoundException.class)
public ResponseEntity<ApiError> handleTaskNotFound() {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiError("TASK_NOT_FOUND", "Uppgiften finns inte."));
}
@ExceptionHandler(AssigneeNotFoundException.class)
public ResponseEntity<ApiError> handleAssigneeNotFound() {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiError("USER_NOT_FOUND", "Användaren finns inte."));
}
@ExceptionHandler(InvalidTaskStatusException.class)
public ResponseEntity<ApiError> handleInvalidTaskStatus() {
return ResponseEntity.badRequest()
.body(new ApiError(
"INVALID_TASK_STATUS",
"Status måste vara WAITING, IN_PROGRESS eller COMPLETED."));
}
@ExceptionHandler(TaskRequiresAssigneeException.class)
public ResponseEntity<ApiError> handleTaskRequiresAssignee() {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ApiError(
"TASK_REQUIRES_ASSIGNEE",
"En pågående uppgift måste ha en ansvarig."));
}
}

View File

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

View File

@ -0,0 +1,22 @@
package se.rubble.hemhub.task;
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,9 @@
package se.rubble.hemhub.task;
public class InvalidTaskException extends RuntimeException {
public InvalidTaskException(String message) {
super(message);
}
}

View File

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

View File

@ -0,0 +1,129 @@
package se.rubble.hemhub.task;
import java.time.Instant;
import java.util.UUID;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.FetchType;
import se.rubble.hemhub.user.User;
@Entity
@Table(name = "task")
class Task {
@Id
private UUID id;
@Column(nullable = false, length = 100)
private String title;
@Column(length = 500)
private String description;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
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)
private Instant createdAt;
protected Task() {
}
Task(
UUID id,
String title,
String description,
TaskStatus status,
int points,
User assignee,
Instant createdAt) {
if (points < 1 || points > 99) {
throw new InvalidTaskException(
"Poäng måste vara ett heltal mellan 1 och 99.");
}
if (status == TaskStatus.IN_PROGRESS && assignee == null) {
throw new TaskRequiresAssigneeException();
}
this.id = id;
this.title = title;
this.description = description;
this.status = status;
this.points = points;
this.assignee = assignee;
this.createdAt = createdAt;
}
UUID getId() {
return id;
}
String getTitle() {
return title;
}
String getDescription() {
return description;
}
TaskStatus getStatus() {
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;
}
void changeDetails(String title, String description, int points) {
if (points < 1 || points > 99) {
throw new InvalidTaskException(
"Poäng måste vara ett heltal mellan 1 och 99.");
}
this.title = title;
this.description = description;
this.points = points;
}
Instant getCreatedAt() {
return createdAt;
}
}

View File

@ -0,0 +1,90 @@
package se.rubble.hemhub.task;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService taskService;
TaskController(TaskService taskService) {
this.taskService = taskService;
}
@GetMapping
public List<TaskResponse> findAll() {
return taskService.findAll();
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public TaskResponse create(@RequestBody(required = false) CreateTaskRequest request) {
UUIDValue assigneeId = request == null
? UUIDValue.optional(null)
: request.parsedAssigneeId();
return taskService.create(
request == null ? null : request.title(),
request == null ? null : request.description(),
request == null ? null : request.integerPoints(),
assigneeId.value());
}
@PutMapping("/{taskId}/assignee")
public TaskResponse updateAssignee(
@PathVariable UUID taskId,
@RequestBody(required = false) UpdateTaskAssigneeRequest request) {
if (request == null) {
throw new InvalidTaskAssignmentException("Fältet assigneeId måste anges.");
}
return taskService.updateAssignee(taskId, request.parsedAssigneeId().value());
}
@PutMapping("/{taskId}/status")
public TaskResponse updateStatus(
@PathVariable UUID taskId,
@RequestBody(required = false) UpdateTaskStatusRequest request) {
if (request == null) {
throw new InvalidTaskStatusException();
}
return taskService.updateStatus(
taskId,
request.parsedStatus(),
request.parsedActiveUserId().value());
}
@PutMapping("/{taskId}/details")
public TaskResponse updateDetails(
@PathVariable UUID taskId,
@RequestBody(required = false) UpdateTaskDetailsRequest request) {
if (request == null) {
throw new InvalidTaskException("Requesten måste innehålla uppgiftsdetaljer.");
}
return taskService.updateDetails(
taskId,
request.parsedTitle(),
request.parsedDescription(),
request.parsedPoints());
}
@DeleteMapping("/{taskId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable UUID taskId) {
taskService.delete(taskId);
}
}

View File

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

View File

@ -0,0 +1,16 @@
package se.rubble.hemhub.task;
import java.util.List;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.EntityGraph;
interface TaskRepository extends JpaRepository<Task, UUID> {
@EntityGraph(attributePaths = "assignee")
List<Task> findAllByOrderByCreatedAtAscIdAsc();
@EntityGraph(attributePaths = "assignee")
java.util.Optional<Task> findOneById(UUID id);
}

View File

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

View File

@ -0,0 +1,32 @@
package se.rubble.hemhub.task;
import java.time.Instant;
import java.util.UUID;
public record TaskResponse(
UUID id,
String title,
String description,
TaskStatus status,
int points,
AssigneeResponse assignee,
Instant createdAt) {
static TaskResponse from(Task task) {
return new TaskResponse(
task.getId(),
task.getTitle(),
task.getDescription(),
task.getStatus(),
task.getPoints(),
AssigneeResponse.from(task.getAssignee()),
task.getCreatedAt());
}
public record AssigneeResponse(UUID id, String name) {
static AssigneeResponse from(se.rubble.hemhub.user.User user) {
return user == null ? null : new AssigneeResponse(user.getId(), user.getName());
}
}
}

View File

@ -0,0 +1,161 @@
package se.rubble.hemhub.task;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import se.rubble.hemhub.user.User;
import se.rubble.hemhub.user.UserRepository;
@Service
class TaskService {
private final TaskRepository taskRepository;
private final UserRepository userRepository;
private final Clock clock;
@Autowired
TaskService(TaskRepository taskRepository, UserRepository userRepository) {
this(taskRepository, userRepository, Clock.systemUTC());
}
TaskService(TaskRepository taskRepository, UserRepository userRepository, Clock clock) {
this.taskRepository = taskRepository;
this.userRepository = userRepository;
this.clock = clock;
}
@Transactional(readOnly = true)
List<TaskResponse> findAll() {
return taskRepository.findAllByOrderByCreatedAtAscIdAsc().stream()
.map(TaskResponse::from)
.toList();
}
@Transactional
TaskResponse create(
String requestedTitle,
String requestedDescription,
Integer requestedPoints,
UUID requestedAssigneeId) {
String title = validateTitle(requestedTitle);
String description = validateDescription(requestedDescription);
int points = validatePoints(requestedPoints);
User assignee = findAssignee(requestedAssigneeId);
Task task = new Task(
UUID.randomUUID(),
title,
description,
TaskStatus.WAITING,
points,
assignee,
Instant.now(clock));
return TaskResponse.from(taskRepository.save(task));
}
@Transactional
TaskResponse updateAssignee(UUID taskId, UUID requestedAssigneeId) {
Task task = taskRepository.findOneById(taskId)
.orElseThrow(TaskNotFoundException::new);
User assignee = findAssignee(requestedAssigneeId);
task.changeAssignee(assignee);
return TaskResponse.from(task);
}
@Transactional
TaskResponse updateStatus(
UUID taskId,
TaskStatus targetStatus,
UUID activeUserId) {
Task task = taskRepository.findOneById(taskId)
.orElseThrow(TaskNotFoundException::new);
User automaticAssignee = null;
if (targetStatus == TaskStatus.IN_PROGRESS && task.getAssignee() == null) {
if (activeUserId == null) {
throw new TaskRequiresAssigneeException();
}
automaticAssignee = findAssignee(activeUserId);
}
task.changeStatus(targetStatus, automaticAssignee);
return TaskResponse.from(task);
}
@Transactional
TaskResponse updateDetails(
UUID taskId,
String requestedTitle,
String requestedDescription,
Integer requestedPoints) {
Task task = taskRepository.findOneById(taskId)
.orElseThrow(TaskNotFoundException::new);
String title = validateTitle(requestedTitle);
String description = validateDescription(requestedDescription);
int points = validatePoints(requestedPoints);
task.changeDetails(title, description, points);
return TaskResponse.from(task);
}
@Transactional
void delete(UUID taskId) {
Task task = taskRepository.findById(taskId)
.orElseThrow(TaskNotFoundException::new);
taskRepository.delete(task);
}
private User findAssignee(UUID requestedAssigneeId) {
if (requestedAssigneeId == null) {
return null;
}
return userRepository.findById(requestedAssigneeId)
.orElseThrow(AssigneeNotFoundException::new);
}
private static String validateTitle(String requestedTitle) {
String title = requestedTitle == null ? "" : requestedTitle.trim();
if (title.isEmpty() || codePointLength(title) > 100) {
throw new InvalidTaskException(
"Titeln måste innehålla mellan 1 och 100 tecken.");
}
return title;
}
private static String validateDescription(String requestedDescription) {
if (requestedDescription == null) {
return null;
}
String description = requestedDescription.trim();
if (description.isEmpty()) {
return null;
}
if (codePointLength(description) > 500) {
throw new InvalidTaskException(
"Beskrivningen får innehålla högst 500 tecken.");
}
return description;
}
private static int validatePoints(Integer requestedPoints) {
if (requestedPoints == null || requestedPoints < 1 || requestedPoints > 99) {
throw new InvalidTaskException(
"Poäng måste vara ett heltal mellan 1 och 99.");
}
return requestedPoints;
}
private static int codePointLength(String value) {
return value.codePointCount(0, value.length());
}
}

View File

@ -0,0 +1,8 @@
package se.rubble.hemhub.task;
public enum TaskStatus {
WAITING,
IN_PROGRESS,
COMPLETED
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.password=
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false
spring.flyway.enabled=true

View File

@ -0,0 +1,8 @@
CREATE TABLE 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
);

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

@ -0,0 +1,342 @@
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.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 static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
class TaskApiTest {
@Autowired
private WebApplicationContext context;
@Autowired
private TaskRepository taskRepository;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
taskRepository.deleteAll();
mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
}
@Test
void createsWaitingTaskWithTrimmedValues() throws Exception {
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": " Dammsuga ",
"description": " Bottenvåningen ",
"points": 7
}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isString())
.andExpect(jsonPath("$.title").value("Dammsuga"))
.andExpect(jsonPath("$.description").value("Bottenvåningen"))
.andExpect(jsonPath("$.status").value("WAITING"))
.andExpect(jsonPath("$.points").value(7))
.andExpect(jsonPath("$.assignee").value((Object) null))
.andExpect(jsonPath("$.createdAt").isString());
mockMvc.perform(get("/api/tasks"))
.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
void storesBlankDescriptionAsNull() throws Exception {
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"title": "Dammsuga", "description": " ", "points": 1}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.description").value((Object) null));
}
@Test
void rejectsBlankAndTooLongTitles() throws Exception {
assertInvalidTask("""
{"title": " ", "points": 1}
""");
assertInvalidTask(
"{\"title\": \"%s\", \"points\": 1}".formatted("a".repeat(101)));
}
@Test
void rejectsTooLongDescription() throws Exception {
assertInvalidTask("""
{"title": "Dammsuga", "description": "%s", "points": 1}
""".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
void listsTasksOldestFirstWithIdAsTieBreaker() throws Exception {
Instant older = Instant.parse("2026-07-24T10:00:00Z");
Instant newer = Instant.parse("2026-07-24T11:00:00Z");
UUID firstId = UUID.fromString("00000000-0000-0000-0000-000000000001");
UUID secondId = UUID.fromString("00000000-0000-0000-0000-000000000002");
UUID newestId = UUID.fromString("00000000-0000-0000-0000-000000000003");
taskRepository.save(new Task(
newestId, "Nyast", null, TaskStatus.WAITING, 3, null, newer));
taskRepository.save(new Task(
secondId, "Andra", null, TaskStatus.WAITING, 2, null, older));
taskRepository.save(new Task(
firstId, "Första", null, TaskStatus.COMPLETED, 1, null, older));
mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value("Första"))
.andExpect(jsonPath("$[0].points").value(1))
.andExpect(jsonPath("$[1].title").value("Andra"))
.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 {
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK"));
}
private UUID createUser(String name) throws Exception {
String response = mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "%s"}
""".formatted(name)))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getContentAsString();
String id = com.jayway.jsonpath.JsonPath.read(response, "$.id");
return UUID.fromString(id);
}
private String createTask(
String title,
String description,
int points,
UUID assigneeId) throws Exception {
String descriptionJson = description == null ? "null" : "\"%s\"".formatted(description);
String assigneeJson = assigneeId == null ? "null" : "\"%s\"".formatted(assigneeId);
String response = mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "%s",
"description": %s,
"points": %d,
"assigneeId": %s
}
""".formatted(title, descriptionJson, points, assigneeJson)))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getContentAsString();
return com.jayway.jsonpath.JsonPath.read(response, "$.id");
}
private ResultActions updateAssignee(String taskId, String body) throws Exception {
return mockMvc.perform(put("/api/tasks/{taskId}/assignee", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content(body));
}
}

View File

@ -0,0 +1,128 @@
package se.rubble.hemhub.task;
import java.time.Instant;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import se.rubble.hemhub.user.User;
import se.rubble.hemhub.user.UserRepository;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
class TaskDeletionApiTest {
@Autowired
private WebApplicationContext context;
@Autowired
private TaskRepository taskRepository;
@Autowired
private UserRepository userRepository;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
taskRepository.deleteAll();
userRepository.deleteAll();
mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
}
@ParameterizedTest
@EnumSource(TaskStatus.class)
void deletesTaskInEveryStatus(TaskStatus statusValue) throws Exception {
User assignee = createUser();
Task task = saveTask(statusValue, assignee);
mockMvc.perform(delete("/api/tasks/{taskId}", task.getId()))
.andExpect(status().isNoContent())
.andExpect(content().string(""));
mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isEmpty());
org.junit.jupiter.api.Assertions.assertTrue(userRepository.existsById(assignee.getId()));
}
@Test
void deletesOnlyRequestedTask() throws Exception {
User assignee = createUser();
Task deleted = saveTask(TaskStatus.WAITING, assignee);
Task remaining = saveTask(TaskStatus.COMPLETED, null);
mockMvc.perform(delete("/api/tasks/{taskId}", deleted.getId()))
.andExpect(status().isNoContent());
mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].id").value(remaining.getId().toString()))
.andExpect(jsonPath("$[0].status").value("COMPLETED"));
org.junit.jupiter.api.Assertions.assertTrue(userRepository.existsById(assignee.getId()));
}
@Test
void returnsNotFoundForUnknownAndAlreadyDeletedTask() throws Exception {
Task task = saveTask(TaskStatus.WAITING, null);
mockMvc.perform(delete("/api/tasks/{taskId}", task.getId()))
.andExpect(status().isNoContent());
mockMvc.perform(delete("/api/tasks/{taskId}", task.getId()))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("TASK_NOT_FOUND"));
mockMvc.perform(delete(
"/api/tasks/{taskId}",
"00000000-0000-0000-0000-000000000099"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("TASK_NOT_FOUND"));
}
@Test
void keepsExistingBadRequestForInvalidUuid() throws Exception {
mockMvc.perform(delete("/api/tasks/{taskId}", "inte-ett-uuid"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT"));
}
private User createUser() throws Exception {
String response = mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "Urban"}
"""))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getContentAsString();
String id = com.jayway.jsonpath.JsonPath.read(response, "$.id");
return userRepository.findById(UUID.fromString(id)).orElseThrow();
}
private Task saveTask(TaskStatus statusValue, User assignee) {
return taskRepository.save(new Task(
UUID.randomUUID(),
"Dammsuga",
"Bottenvåningen",
statusValue,
7,
assignee,
Instant.parse("2026-07-28T09:00:00Z")));
}
}

View File

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

View File

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

View File

@ -0,0 +1,62 @@
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.assertEquals;
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")));
}
@Test
void changesOnlyEditableDetailsAndProtectsPointsInvariant() {
Task task = taskWithPoints(3);
UUID id = task.getId();
TaskStatus status = task.getStatus();
Instant createdAt = task.getCreatedAt();
task.changeDetails("Ny titel", "Ny beskrivning", 7);
assertEquals(id, task.getId());
assertEquals("Ny titel", task.getTitle());
assertEquals("Ny beskrivning", task.getDescription());
assertEquals(7, task.getPoints());
assertEquals(status, task.getStatus());
assertEquals(createdAt, task.getCreatedAt());
assertThrows(InvalidTaskException.class, () -> task.changeDetails("Titel", null, 0));
assertThrows(InvalidTaskException.class, () -> task.changeDetails("Titel", null, 100));
}
private Task taskWithPoints(int points) {
return new Task(
UUID.randomUUID(),
"Dammsuga",
null,
TaskStatus.WAITING,
points,
null,
Instant.parse("2026-07-26T12:00:00Z"));
}
}

227
docs/architecture.md Normal file
View File

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

470
docs/roadmap.md Normal file
View File

@ -0,0 +1,470 @@
# 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 08 är klara och finns på `main`. Den aktuella applikationen har:
- ett monorepo med separat React/Vite-frontend och Spring Boot-backend;
- centralt lagrade användare och ett lokalt browserval av aktiv användare;
- gemensamma uppgifter med titel, valfri beskrivning, status och poäng;
- skapande och listning av uppgifter;
- valfri tilldelning av högst en ansvarig användare per uppgift;
- tilldelning och byte av ansvarig i samtliga statusar;
- borttagning av ansvarig i `WAITING` och `COMPLETED`;
- backendstyrda statusändringar mellan `WAITING`, `IN_PROGRESS` och `COMPLETED`;
- automatisk tilldelning till aktiv användare när en otilldelad uppgift påbörjas;
- drag-and-drop mellan statuskolumner med optimistisk flytt och rollback;
- serverbekräftad permanent radering med bekräftelsedialog;
- serverbekräftad redigering av titel, beskrivning och poäng;
- en bräda med Väntande, Pågående och Klart;
- nya uppgifter som alltid skapas med status `WAITING`.
Tilldelning och status är separata egenskaper; tilldelningsflödet ändrar inte
uppgiftens status. Alla direkta statusövergångar är tillåtna och
`IN_PROGRESS` kräver ansvarig. Det finns ännu ingen deadline eller återkommande
uppgift. Nuvarande användarval är inte autentisering.
**Feature 9 Deadline är nästa planerade produktfeature.**
## 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 | Klar | 4 | Backendstyrda statusövergångar |
| 6 Drag-and-drop | Klar | 5 | Kortflytt via status-API |
| 7 Radera uppgift | Klar | 2 | Bekräftad permanent radering |
| 8 Redigera uppgift | Klar | 3 | Titel, beskrivning och poäng |
| 9 Deadline | Planerad | 2 | Valfri deadline och förseningsmarkering |
| 10 Sökning och filtrering | Planerad | 2; 4 för ansvarig; 9 för deadline | Sökning och filter på brädan |
| 11 Design av återkommande uppgifter | Planerad | 35, 9 | Beslut och plan, ingen produktionskod |
| 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:** Klar
**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:** Klar
**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.
Drag-and-drop använder en kontrollerad optimistisk flytt. Vid fel återställs
hela den tidigare task-versionen. En otilldelad uppgift som dras till Pågående
använder Feature 5:s befintliga automatiska tilldelning till aktiv användare.
Serverns fullständiga task-respons ersätter alltid det optimistiska värdet.
Statusknapparna förblir tills vidare serverbekräftade. Implementation och
automatisk samt manuell verifiering är genomförda, och featuren är mergad till
`main`.
## Fas 2 Hantering av uppgifter
Fasen kompletterar livscykeln för enskilda uppgifter efter att den centrala
modellen och statusreglerna finns.
### Feature 7 Radera uppgift
**Status:** Klar
**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.
Feature 7 använder permanent fysisk radering genom
`DELETE /api/tasks/{taskId}`. En bekräftelsemodal visas före anropet och
frontend behåller kortet tills backend har bekräftat raderingen. Operationen
använder samma låsning per task-id som status, tilldelning och drag-and-drop.
Ett `404 TASK_NOT_FOUND` tar bort ett känt inaktuellt lokalt kort.
Implementation samt automatisk och manuell verifiering är genomförda, och
featuren är mergad till `main`.
### Feature 8 Redigera uppgift
**Status:** Klar
**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.
Den implementerade lösningen använder `PUT /api/tasks/{taskId}/details` och
uppdaterar endast titel, beskrivning och poäng. Frontend använder en separat
redigeringsmodal och serverbekräftad uppdatering genom den gemensamma låsningen
per task-id. Implementation, automatiska kontroller och manuell
browserverifiering är genomförda, och featuren är mergad till `main`.
### Feature 9 Deadline
**Status:** Planerad
**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
- 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-28: Feature 8 verifierades och mergades. Serverbekräftad redigering
av titel, beskrivning och poäng infördes, och Feature 9 blev nästa planerade
produktfeature.
- 2026-07-27: Feature 8 implementerades och verifierades automatiskt på
feature-branchen. Manuell verifiering och merge återstår.
- 2026-07-27: Feature 7 verifierades och mergades. Permanent,
serverbekräftad radering infördes, och Feature 8 blev nästa planerade
produktfeature.
- 2026-07-27: Feature 6 verifierades och mergades. Optimistisk drag-and-drop
med full rollback infördes, och Feature 7 blev nästa planerade
produktfeature.
- 2026-07-27: Feature 5 verifierades och mergades. Backendstyrda
statusövergångar, automatisk tilldelning vid påbörjande och statusberoende
tilldelningsregler infördes. Feature 6 blev nästa planerade produktfeature.
- 2026-07-26: Feature 3 och Feature 4 markerades som klara efter verifiering och
merge. Feature 5 blev nästa planerade produktfeature.
- 2026-07-26: Roadmapen etablerades. Feature 02 markerades som klara, Feature
316 planerades och Feature 17 markerades som villkorad.

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
import { FormEvent, useEffect, useRef, useState } from 'react'
import TaskBoard from './TaskBoard'
type User = {
id: string
@ -55,19 +56,19 @@ function App() {
setActiveUser(user)
}
const switchUser = () => {
const logOut = () => {
window.localStorage.removeItem(ACTIVE_USER_KEY)
setActiveUser(null)
setShowCreateUser(false)
}
if (loadState === 'loading') {
return <main>Laddar HemHub</main>
return <main className="panel">Laddar HemHub</main>
}
if (loadState === 'error') {
return (
<main>
<main className="panel">
<h1>HemHub</h1>
<p>Kunde inte ansluta till HemHub.</p>
<button type="button" onClick={() => void loadUsers()}>
@ -79,14 +80,12 @@ function App() {
if (activeUser) {
return (
<main>
<h1>HemHub</h1>
<h2>Hej {activeUser.name}</h2>
<p>Du är vald som aktiv användare.</p>
<button type="button" onClick={switchUser}>
Byt användare
</button>
</main>
<TaskBoard
activeUserId={activeUser.id}
activeUserName={activeUser.name}
users={users}
onLogOut={logOut}
/>
)
}
@ -104,7 +103,7 @@ function App() {
}
return (
<main>
<main className="panel">
<h1>HemHub</h1>
<h2>Vem är du?</h2>
<div className="user-list">
@ -183,7 +182,7 @@ function CreateUserForm({ hasExistingUsers, onCancel, onCreated }: CreateUserFor
}
return (
<main>
<main className="panel">
<h1>HemHub</h1>
<h2>Skapa användare</h2>
<form onSubmit={(event) => void submit(event)}>

1095
frontend/src/TaskBoard.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -8,7 +8,7 @@ body {
margin: 0;
}
main {
.panel {
max-width: 40rem;
margin: 6rem auto;
padding: 2rem;
@ -22,7 +22,9 @@ h1 {
}
button,
input {
input,
select,
textarea {
font: inherit;
}
@ -36,7 +38,9 @@ button {
}
button:disabled,
input:disabled {
input:disabled,
select:disabled,
textarea:disabled {
cursor: not-allowed;
opacity: 0.65;
}
@ -68,6 +72,362 @@ input {
border-radius: 0.4rem;
}
select {
box-sizing: border-box;
width: 100%;
padding: 0.6rem;
border: 1px solid #9ca3af;
border-radius: 0.4rem;
background: white;
}
textarea {
box-sizing: border-box;
width: 100%;
padding: 0.6rem;
border: 1px solid #9ca3af;
border-radius: 0.4rem;
resize: vertical;
}
.error {
margin: 0;
color: #b91c1c;
}
.task-app {
min-height: 100vh;
padding: 2rem clamp(1rem, 4vw, 4rem);
background: #f3f4f6;
}
.app-header,
.board-toolbar,
.user-controls,
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.app-header {
margin: 0 auto 2rem;
max-width: 90rem;
}
.app-header h1,
.eyebrow {
margin: 0;
}
.eyebrow {
color: #64748b;
font-weight: 600;
}
.user-controls {
justify-content: flex-end;
}
.compact {
margin-top: 0;
}
.board-toolbar {
min-height: 2.75rem;
margin: 0 auto 1rem;
max-width: 90rem;
}
.link-button {
padding: 0.25rem 0.5rem;
color: #2563eb;
background: transparent;
text-decoration: underline;
}
.board {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
margin: 0 auto;
max-width: 90rem;
}
.board-column {
min-height: 20rem;
padding: 1rem;
border: 1px solid transparent;
border-radius: 0.75rem;
background: #e5e7eb;
transition: border-color 120ms ease, background-color 120ms ease;
}
.board-column-drop-target {
border-color: #93c5fd;
background: #e0e7ff;
}
.board-column h2 {
margin: 0 0 1rem;
font-size: 1.1rem;
}
.task-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.task-card {
padding: 1rem;
border-radius: 0.5rem;
background: white;
box-shadow: 0 0.125rem 0.4rem rgb(0 0 0 / 8%);
}
.task-card-pending {
opacity: 0.65;
}
.task-card-dragging {
cursor: grabbing;
}
.task-card h3,
.task-card p {
margin: 0;
}
.task-assignment {
margin-top: 0.9rem;
}
.task-assignee {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: #475569;
font-size: 0.9rem;
}
.task-assignee-button {
padding: 0.25rem 0;
color: #2563eb;
background: transparent;
}
.user-icon {
flex: 0 0 auto;
}
.assignee-select-label {
display: flex;
align-items: center;
gap: 0.4rem;
}
.assignee-select-label select {
width: auto;
min-width: 9rem;
}
.field-label-uppercase {
color: #64748b;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.task-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.task-card-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.35rem;
}
.task-edit-button,
.task-delete-button {
display: inline-grid;
width: 2.5rem;
height: 2.5rem;
padding: 0;
place-items: center;
color: #64748b;
background: transparent;
}
.task-edit-button:hover,
.task-edit-button:focus-visible {
color: #1d4ed8;
background: #dbeafe;
}
.task-edit-button:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.task-delete-button:hover,
.task-delete-button:focus-visible {
color: #991b1b;
background: #fee2e2;
}
.task-delete-button:focus-visible {
outline: 2px solid #dc2626;
outline-offset: 2px;
}
.points-badge {
flex: 0 0 auto;
padding: 0.2rem 0.5rem;
border-radius: 999px;
color: #1e3a8a;
background: #dbeafe;
font-size: 0.8rem;
font-weight: 700;
line-height: 1.25;
}
.task-card p {
margin-top: 0.5rem;
color: #475569;
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 {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: 1rem;
background: rgb(15 23 42 / 55%);
}
.modal {
width: min(100%, 34rem);
padding: 1.5rem;
border-radius: 0.75rem;
background: white;
box-shadow: 0 1rem 3rem rgb(0 0 0 / 25%);
}
.modal-header {
margin-bottom: 1.25rem;
}
.modal-header h2 {
margin: 0;
}
.delete-task-modal p {
margin: 0 0 1rem;
}
.edit-task-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1rem;
}
.edit-task-actions .secondary {
margin-top: 0;
}
.delete-task-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.delete-task-actions .secondary {
margin-top: 0;
}
.danger {
background: #b91c1c;
}
.danger:hover,
.danger:focus-visible {
background: #991b1b;
}
.close-button {
padding: 0.2rem 0.55rem;
color: #475569;
background: transparent;
font-size: 1.75rem;
line-height: 1;
}
@media (max-width: 48rem) {
.panel {
margin: 2rem 1rem;
}
.app-header {
align-items: flex-start;
}
.user-controls {
flex-direction: column;
align-items: flex-end;
}
.board {
grid-template-columns: 1fr;
}
.board-column {
min-height: 8rem;
}
}

View File

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