5 Commits

44 changed files with 2428 additions and 42 deletions

3
.gitignore vendored
View File

@ -1,5 +1,8 @@
# Backend # Backend
backend/target/ backend/target/
backend/data/
backend/*.mv.db
backend/*.trace.db
# Frontend # Frontend
frontend/node_modules/ frontend/node_modules/

View File

@ -9,4 +9,7 @@
- Affärsregler ska senare säkerställas i backend och inte enbart i frontend. - Affärsregler ska senare säkerställas i backend och inte enbart i frontend.
- Kod, tester och dokumentation ska hållas uppdaterade tillsammans. - Kod, tester och dokumentation ska hållas uppdaterade tillsammans.
- Större arkitekturella beslut ska diskuteras innan de införs. - Större arkitekturella beslut ska diskuteras innan de införs.
- Aktuell arkitektur, utvecklingsprocess, beslut och featurehistorik dokumenteras
under `docs/`.
- En feature ska uppdatera berörda dokument så att repositoryt förblir projektets
facit även efter att feature-branchen har raderats.

View File

@ -6,6 +6,12 @@ innehåller två separata applikationer:
- en backend byggd med Java 21, Spring Boot och Maven - en backend byggd med Java 21, Spring Boot och Maven
- en frontend byggd med React, TypeScript, Vite och pnpm - en frontend byggd med React, TypeScript, Vite och pnpm
Backend använder en lokal filbaserad H2-databas i `backend/data`. Databasschemat
hanteras med Flyway. Databasfilerna är lokala och ignoreras av Git.
API:t innehåller endpoints under `/api/users` för användare och `/api/tasks` för
att skapa och lista gemensamma hushållsuppgifter.
## Starta backend ## Starta backend
Backend startar på port 8080. Backend startar på port 8080.
@ -44,3 +50,14 @@ cd frontend
pnpm test pnpm test
``` ```
## Dokumentation
Projektets aktuella arkitektur, utvecklingsprocess, övergripande beslut och
featurehistorik finns i [`docs/`](docs/):
- [arkitektur](docs/architecture.md)
- [utvecklingsprocess](docs/development.md)
- [arkitekturbeslut](docs/decisions/)
- [implementerade features](docs/features/)
Dokumentationen ska uppdateras tillsammans med implementation och tester.

View File

@ -26,6 +26,19 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
@ -42,4 +55,3 @@
</plugins> </plugins>
</build> </build>
</project> </project>

View File

@ -0,0 +1,5 @@
package se.rubble.hemhub.api;
public record ApiError(String code, String message) {
}

View File

@ -0,0 +1,37 @@
package se.rubble.hemhub.api;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import se.rubble.hemhub.task.InvalidTaskException;
import se.rubble.hemhub.user.InvalidUserNameException;
import se.rubble.hemhub.user.UserNameAlreadyExistsException;
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(InvalidUserNameException.class)
public ResponseEntity<ApiError> handleInvalidUserName() {
return ResponseEntity.badRequest()
.body(new ApiError(
"INVALID_USER_NAME",
"Namnet måste innehålla mellan 1 och 50 tecken."));
}
@ExceptionHandler(UserNameAlreadyExistsException.class)
public ResponseEntity<ApiError> handleDuplicateUserName() {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ApiError(
"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()));
}
}

View File

@ -0,0 +1,5 @@
package se.rubble.hemhub.task;
public record CreateTaskRequest(String title, String description) {
}

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,69 @@
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.Table;
@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(name = "created_at", nullable = false)
private Instant createdAt;
protected Task() {
}
Task(
UUID id,
String title,
String description,
TaskStatus status,
Instant createdAt) {
this.id = id;
this.title = title;
this.description = description;
this.status = status;
this.createdAt = createdAt;
}
UUID getId() {
return id;
}
String getTitle() {
return title;
}
String getDescription() {
return description;
}
TaskStatus getStatus() {
return status;
}
Instant getCreatedAt() {
return createdAt;
}
}

View File

@ -0,0 +1,36 @@
package se.rubble.hemhub.task;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.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) {
return taskService.create(
request == null ? null : request.title(),
request == null ? null : request.description());
}
}

View File

@ -0,0 +1,12 @@
package se.rubble.hemhub.task;
import java.util.List;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
interface TaskRepository extends JpaRepository<Task, UUID> {
List<Task> findAllByOrderByCreatedAtAscIdAsc();
}

View File

@ -0,0 +1,22 @@
package se.rubble.hemhub.task;
import java.time.Instant;
import java.util.UUID;
public record TaskResponse(
UUID id,
String title,
String description,
TaskStatus status,
Instant createdAt) {
static TaskResponse from(Task task) {
return new TaskResponse(
task.getId(),
task.getTitle(),
task.getDescription(),
task.getStatus(),
task.getCreatedAt());
}
}

View File

@ -0,0 +1,73 @@
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;
@Service
class TaskService {
private final TaskRepository taskRepository;
private final Clock clock;
@Autowired
TaskService(TaskRepository taskRepository) {
this(taskRepository, Clock.systemUTC());
}
TaskService(TaskRepository taskRepository, Clock clock) {
this.taskRepository = taskRepository;
this.clock = clock;
}
@Transactional(readOnly = true)
List<TaskResponse> findAll() {
return taskRepository.findAllByOrderByCreatedAtAscIdAsc().stream()
.map(TaskResponse::from)
.toList();
}
@Transactional
TaskResponse create(String requestedTitle, String requestedDescription) {
String title = requestedTitle == null ? "" : requestedTitle.trim();
String description = normalizeDescription(requestedDescription);
if (title.isEmpty() || codePointLength(title) > 100) {
throw new InvalidTaskException(
"Titeln måste innehålla mellan 1 och 100 tecken.");
}
if (description != null && codePointLength(description) > 500) {
throw new InvalidTaskException(
"Beskrivningen får innehålla högst 500 tecken.");
}
Task task = new Task(
UUID.randomUUID(),
title,
description,
TaskStatus.WAITING,
Instant.now(clock));
return TaskResponse.from(taskRepository.save(task));
}
private static String normalizeDescription(String requestedDescription) {
if (requestedDescription == null) {
return null;
}
String description = requestedDescription.trim();
return description.isEmpty() ? null : description;
}
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,5 @@
package se.rubble.hemhub.user;
public record CreateUserRequest(String name) {
}

View File

@ -0,0 +1,5 @@
package se.rubble.hemhub.user;
public class InvalidUserNameException extends RuntimeException {
}

View File

@ -0,0 +1,48 @@
package se.rubble.hemhub.user;
import java.time.Instant;
import java.util.UUID;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "app_user")
class User {
@Id
private UUID id;
@Column(nullable = false, length = 50)
private String name;
@Column(name = "normalized_name", nullable = false, length = 150, unique = true)
private String normalizedName;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
protected User() {
}
User(UUID id, String name, String normalizedName, Instant createdAt) {
this.id = id;
this.name = name;
this.normalizedName = normalizedName;
this.createdAt = createdAt;
}
UUID getId() {
return id;
}
String getName() {
return name;
}
Instant getCreatedAt() {
return createdAt;
}
}

View File

@ -0,0 +1,34 @@
package se.rubble.hemhub.user;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.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/users")
public class UserController {
private final UserService userService;
UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public List<UserResponse> findAll() {
return userService.findAll();
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserResponse create(@RequestBody(required = false) CreateUserRequest request) {
return userService.create(request == null ? null : request.name());
}
}

View File

@ -0,0 +1,5 @@
package se.rubble.hemhub.user;
public class UserNameAlreadyExistsException extends RuntimeException {
}

View File

@ -0,0 +1,11 @@
package se.rubble.hemhub.user;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
interface UserRepository extends JpaRepository<User, UUID> {
boolean existsByNormalizedName(String normalizedName);
}

View File

@ -0,0 +1,12 @@
package se.rubble.hemhub.user;
import java.time.Instant;
import java.util.UUID;
public record UserResponse(UUID id, String name, Instant createdAt) {
static UserResponse from(User user) {
return new UserResponse(user.getId(), user.getName(), user.getCreatedAt());
}
}

View File

@ -0,0 +1,66 @@
package se.rubble.hemhub.user;
import java.time.Clock;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class UserService {
private static final Comparator<User> BY_DISPLAY_NAME =
Comparator.comparing(User::getName, String.CASE_INSENSITIVE_ORDER)
.thenComparing(User::getName)
.thenComparing(User::getId);
private final UserRepository userRepository;
private final Clock clock;
@Autowired
UserService(UserRepository userRepository) {
this(userRepository, Clock.systemUTC());
}
UserService(UserRepository userRepository, Clock clock) {
this.userRepository = userRepository;
this.clock = clock;
}
@Transactional(readOnly = true)
List<UserResponse> findAll() {
return userRepository.findAll().stream()
.sorted(BY_DISPLAY_NAME)
.map(UserResponse::from)
.toList();
}
@Transactional
UserResponse create(String requestedName) {
String name = requestedName == null ? "" : requestedName.trim();
if (name.isEmpty() || name.codePointCount(0, name.length()) > 50) {
throw new InvalidUserNameException();
}
String normalizedName = name.toLowerCase(Locale.ROOT);
if (userRepository.existsByNormalizedName(normalizedName)) {
throw new UserNameAlreadyExistsException();
}
User user = new User(UUID.randomUUID(), name, normalizedName, Instant.now(clock));
try {
return UserResponse.from(userRepository.saveAndFlush(user));
} catch (DataIntegrityViolationException exception) {
throw new UserNameAlreadyExistsException();
}
}
}

View File

@ -0,0 +1,7 @@
spring.datasource.url=jdbc:h2:file:./data/hemhub;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH
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,7 @@
CREATE TABLE app_user (
id UUID PRIMARY KEY,
name VARCHAR(50) NOT NULL,
normalized_name VARCHAR(150) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
CONSTRAINT uk_app_user_normalized_name UNIQUE (normalized_name)
);

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,107 @@
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.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.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 "
}
"""))
.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("$.createdAt").isString());
}
@Test
void storesBlankDescriptionAsNull() throws Exception {
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"title": "Dammsuga", "description": " "}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.description").value((Object) null));
}
@Test
void rejectsBlankAndTooLongTitles() throws Exception {
assertInvalidTask("""
{"title": " "}
""");
assertInvalidTask("{\"title\": \"%s\"}".formatted("a".repeat(101)));
}
@Test
void rejectsTooLongDescription() throws Exception {
assertInvalidTask("""
{"title": "Dammsuga", "description": "%s"}
""".formatted("a".repeat(501)));
}
@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, newer));
taskRepository.save(new Task(secondId, "Andra", null, TaskStatus.IN_PROGRESS, older));
taskRepository.save(new Task(firstId, "Första", null, TaskStatus.COMPLETED, older));
mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value("Första"))
.andExpect(jsonPath("$[1].title").value("Andra"))
.andExpect(jsonPath("$[2].title").value("Nyast"));
}
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"));
}
}

View File

@ -0,0 +1,108 @@
package se.rubble.hemhub.user;
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.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.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
class UserApiTest {
@Autowired
private WebApplicationContext context;
@Autowired
private UserRepository userRepository;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
userRepository.deleteAll();
mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
}
@Test
void listsNoUsersWhenDatabaseIsEmpty() throws Exception {
mockMvc.perform(get("/api/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isEmpty());
}
@Test
void createsAndListsUserWithTrimmedName() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": " Urban "}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isString())
.andExpect(jsonPath("$.name").value("Urban"))
.andExpect(jsonPath("$.createdAt").isString())
.andExpect(jsonPath("$.normalizedName").doesNotExist());
mockMvc.perform(get("/api/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value("Urban"));
}
@Test
void rejectsInvalidNames() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": " "}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_USER_NAME"));
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"%s\"}".formatted("a".repeat(51))))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_USER_NAME"));
}
@Test
void rejectsDuplicateNameIgnoringCase() throws Exception {
createUser("Urban");
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "urban"}
"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("USER_NAME_ALREADY_EXISTS"));
}
@Test
void listsUsersSortedByDisplayName() throws Exception {
createUser("Urban");
createUser("Anna");
createUser("Bertil");
mockMvc.perform(get("/api/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value("Anna"))
.andExpect(jsonPath("$[1].name").value("Bertil"))
.andExpect(jsonPath("$[2].name").value("Urban"));
}
private void createUser(String name) throws Exception {
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"%s\"}".formatted(name)))
.andExpect(status().isCreated());
}
}

View File

@ -0,0 +1,7 @@
spring.datasource.url=jdbc:h2:mem:hemhub-test;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

183
docs/architecture.md Normal file
View File

@ -0,0 +1,183 @@
# HemHubs arkitektur
Detta dokument beskriver den arkitektur som kan verifieras i repositoryts kod,
tester och konfiguration. Historiska implementationssteg finns under
[`features/`](features/) och övergripande beslut under
[`decisions/`](decisions/).
## Aktuell implementation
### Monorepo
HemHub ligger i ett Git-repository med två separata applikationer:
```text
hemhub/
├── backend/
├── frontend/
└── docs/
```
Applikationerna har egna byggverktyg och beroenden. De delar inte källkod eller
byggprocess.
### Frontend
Frontend finns i `frontend/` och använder React 19, TypeScript, Vite och pnpm.
Den ansvarar för:
- hämtning och presentation av användare och uppgifter;
- lokalt val av aktiv användare;
- formulär för att skapa användare och uppgifter;
- 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`
### Databas och migreringar
Lokal körning använder en filbaserad H2-databas under `backend/data`. Katalogen
ignoreras av Git. 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`
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`;
- `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`. Det finns ingen relation mellan uppgifter och
användare; alla aktiva användare ser samma uppgiftslista.
### 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`
och dubbletter av användarnamn 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.
### Teststrategi
Backend har JUnit 5-tester:
- ett fristående MockMvc-test för health-endpointen;
- Spring Boot-integrationstester via MockMvc mot H2 in-memory för användar- och
uppgifts-API.
Frontend använder Vitest, jsdom och React Testing Library. `fetch` och
`localStorage` ersätts i testerna, så frontendtesterna kräver inte en körande
backend. Produktionsbygget kör TypeScript-kompilering följt av Vite.
### Produktionsdeployment
Ingen produktionsdeployment är implementerad i repositoryt. Det finns inga
Dockerfiler, pipelinefiler eller produktionsspecifika Nginx-, Watchtower- eller
databaskonfigurationer. H2 används både lokalt och i automatiska tester; någon
PostgreSQL-konfiguration finns ännu inte.
## Beslutad planerad riktning
Repositoryt anger att affärsregler även framöver ska säkerställas i backend och
att större arkitekturella beslut ska diskuteras innan de införs.
Följande produktionsriktning är beslutad men ännu inte implementerad:
- PostgreSQL ska användas som produktionsdatabas.
- Frontend och backend ska paketeras som separata Docker-images.
- Källkoden ligger i Gitea.
- Drone ska bygga och publicera images till ett privat registry.
- Watchtower ska uppdatera de körande tjänsterna när nya images publiceras.
- Nginx kan användas som reverse proxy framför tjänsterna.
- Produktionsmiljön ska köras på Ubuntu-servern Biff.
Den planerade riktningen beskrivs även i
[`005-production-deployment-direction.md`](decisions/005-production-deployment-direction.md).
Punkterna ovan beskriver målbilden och ska inte tolkas som att motsvarande
konfiguration redan finns eller har verifierats.
## Fortfarande öppna detaljer
Följande har inte fastställts i dokumentationen och ska beslutas i samband med
att produktionslösningen implementeras:
- exakt containerstruktur och tjänsteindelning;
- image-namn och taggningsstrategi;
- produktions-URL;
- hantering och distribution av secrets;
- exakt Nginx-konfiguration;
- exakt Drone-, registry-, Watchtower- och deploymentkonfiguration.
Miljöspecifika adresser, credentials och secrets ska inte lagras i dessa
arkitekturdokument.

View File

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

View File

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

View File

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

View File

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

View File

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

58
docs/development.md Normal file
View File

@ -0,0 +1,58 @@
# 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.
- 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. Skapa en avgränsad branch.
3. Skapa eller uppdatera feature-dokumentet.
4. Implementera specifikationen.
5. Kör relevanta automatiska tester och bygge.
6. Gör manuell verifiering där det är relevant.
7. Uppdatera dokumentationen så att den beskriver den faktiska lösningen.
8. Commit och push efter uttrycklig instruktion.
9. Merge efter verifiering.
10. Radera den mergade branchen.
## Verifiering före merge
För nuvarande projekt bör verifieringen normalt omfatta:
```bash
cd backend
./mvnw test
```
```bash
cd frontend
pnpm test
pnpm build
```
Kör även `git diff --check` och granska `git status --short`. Manuell lokal
verifiering av berörda flöden kompletterar, men ersätter inte, automatiska
tester.
Om ett befintligt test misslyckas av ett skäl utanför ändringens omfattning ska
det rapporteras; produktionskod ska inte ändras enbart för att dölja felet.

View File

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

View File

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

View File

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

View File

@ -1,21 +1,314 @@
import { render, screen } from '@testing-library/react' import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, expect, test, vi } from 'vitest' import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import App from './App' import App from './App'
const users = [
{
id: 'd56b54dd-31b0-4d71-8a10-82464be59a61',
name: 'Urban',
createdAt: '2026-07-24T10:15:30Z',
},
{
id: '2213a673-c859-462b-a031-7b68b6f01c73',
name: 'Anna',
createdAt: '2026-07-24T10:16:30Z',
},
]
const tasks = [
{
id: '00000000-0000-0000-0000-000000000001',
title: 'Dammsuga',
description: 'Bottenvåningen',
status: 'WAITING',
createdAt: '2026-07-24T10:00:00Z',
},
{
id: '00000000-0000-0000-0000-000000000002',
title: 'Diska',
description: null,
status: 'IN_PROGRESS',
createdAt: '2026-07-24T10:01:00Z',
},
{
id: '00000000-0000-0000-0000-000000000003',
title: 'Vattna blommor',
description: null,
status: 'COMPLETED',
createdAt: '2026-07-24T10:02:00Z',
},
]
beforeEach(() => {
window.localStorage.clear()
})
afterEach(() => { afterEach(() => {
cleanup()
vi.restoreAllMocks() vi.restoreAllMocks()
}) })
test('visar sidans rubrik', () => { test('tom användarlista visar Skapa användare', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue( mockJsonResponse([])
new Response(JSON.stringify({ status: 'UP' }), {
status: 200, render(<App />)
headers: { 'Content-Type': 'application/json' },
}), expect(await screen.findByRole('heading', { name: 'Skapa användare' })).toBeInTheDocument()
})
test('befintliga användare utan lokalt val visar Vem är du', async () => {
mockJsonResponse(users)
render(<App />)
expect(await screen.findByRole('heading', { name: 'Vem är du?' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Urban' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Anna' })).toBeInTheDocument()
})
test('val av användare lagrar id och visar brädan', async () => {
mockUsersAndTasks(users, [])
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Urban' }))
expect(await screen.findByRole('heading', { name: 'Uppgifter' })).toBeInTheDocument()
expect(screen.getByText('Urban')).toBeInTheDocument()
expect(window.localStorage.getItem('hemhub.activeUserId')).toBe(users[0].id)
})
test('skapad användare blir automatiskt aktiv', async () => {
const createdUser = users[0]
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse([]))
fetchMock.mockResolvedValueOnce(jsonResponse(createdUser, 201))
fetchMock.mockResolvedValueOnce(jsonResponse([]))
render(<App />)
fireEvent.change(await screen.findByLabelText('Namn'), { target: { value: ' Urban ' } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa användare' }))
expect(await screen.findByRole('heading', { name: 'Uppgifter' })).toBeInTheDocument()
expect(window.localStorage.getItem('hemhub.activeUserId')).toBe(createdUser.id)
expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Urban' }),
})
})
test('skapandefel för användare visas utan att namnet försvinner', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse([]))
fetchMock.mockResolvedValueOnce(
jsonResponse(
{
code: 'USER_NAME_ALREADY_EXISTS',
message: 'En användare med det namnet finns redan.',
},
409,
),
) )
render(<App />) render(<App />)
expect(screen.getByRole('heading', { name: 'HemHub' })).toBeInTheDocument() const input = await screen.findByLabelText('Namn')
fireEvent.change(input, { target: { value: 'Urban' } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa användare' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'En användare med det namnet finns redan.',
)
expect(input).toHaveValue('Urban')
}) })
test('Logga ut rensar valt id och väljer inte användaren automatiskt', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
mockUsersAndTasks(users, [])
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Logga ut' }))
expect(screen.getByRole('heading', { name: 'Vem är du?' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Urban' })).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Uppgifter' })).not.toBeInTheDocument()
expect(window.localStorage.getItem('hemhub.activeUserId')).toBeNull()
})
test('hämtningsfel för användare visas som fel och kan återförsökas', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockRejectedValueOnce(new Error('Nätverksfel'))
fetchMock.mockResolvedValueOnce(jsonResponse(users))
render(<App />)
expect(await screen.findByText('Kunde inte ansluta till HemHub.')).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Skapa användare' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Försök igen' }))
expect(await screen.findByRole('heading', { name: 'Vem är du?' })).toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledTimes(2)
})
test('ogiltigt lagrat användar-id rensas', async () => {
window.localStorage.setItem('hemhub.activeUserId', 'finns-inte')
mockJsonResponse(users)
render(<App />)
expect(await screen.findByRole('heading', { name: 'Vem är du?' })).toBeInTheDocument()
expect(window.localStorage.getItem('hemhub.activeUserId')).toBeNull()
})
test('brädan visar tre kolumner och grupperar hämtade uppgifter', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
mockUsersAndTasks(users, tasks)
render(<App />)
const waiting = await screen.findByRole('region', { name: 'Väntande' })
const inProgress = screen.getByRole('region', { name: 'Pågående' })
const completed = screen.getByRole('region', { name: 'Klart' })
expect(within(waiting).getByText('Dammsuga')).toBeInTheDocument()
expect(within(waiting).getByText('Bottenvåningen')).toBeInTheDocument()
expect(within(inProgress).getByText('Diska')).toBeInTheDocument()
expect(within(completed).getByText('Vattna blommor')).toBeInTheDocument()
expect(screen.queryByText(/Inga uppgifter/i)).not.toBeInTheDocument()
})
test('Ny uppgift öppnar modalen med fokus i titelfältet', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
mockUsersAndTasks(users, [])
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Ny uppgift' }))
expect(screen.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument()
expect(screen.getByLabelText('Titel')).toHaveFocus()
})
test.each([
{
sätt: 'stängningskrysset',
close: () => fireEvent.click(screen.getByRole('button', { name: 'Stäng' })),
},
{
sätt: 'Escape',
close: () => fireEvent.keyDown(window, { key: 'Escape' }),
},
{
sätt: 'modalens bakgrund',
close: () => {
const backdrop = screen.getByRole('dialog', { name: 'Skapa ny uppgift' }).parentElement
if (!backdrop) {
throw new Error('Modalens bakgrund saknas')
}
fireEvent.mouseDown(backdrop)
},
},
])('modalen stängs med $sätt och rensar formuläret', async ({ close }) => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
mockUsersAndTasks(users, [])
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Ny uppgift' }))
fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Dammsuga' } })
fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), {
target: { value: 'Bottenvåningen' },
})
close()
expect(screen.queryByRole('dialog', { name: 'Skapa ny uppgift' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' }))
expect(screen.getByLabelText('Titel')).toHaveValue('')
expect(screen.getByLabelText('Beskrivning (valfri)')).toHaveValue('')
})
test('en skapad uppgift visas längst ned i Väntande och modalen stängs', async () => {
const createdTask = {
id: '00000000-0000-0000-0000-000000000004',
title: 'Putsa fönster',
description: 'Köket',
status: 'WAITING',
createdAt: '2026-07-24T10:03:00Z',
}
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]]))
fetchMock.mockResolvedValueOnce(jsonResponse(createdTask, 201))
render(<App />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' }))
fireEvent.change(screen.getByLabelText('Titel'), { target: { value: ' Putsa fönster ' } })
fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), {
target: { value: ' Köket ' },
})
fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' }))
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: 'Skapa ny uppgift' })).not.toBeInTheDocument(),
)
const waiting = screen.getByRole('region', { name: 'Väntande' })
expect(within(waiting).getAllByRole('article').map((card) => card.textContent)).toEqual([
'DammsugaBottenvåningen',
'Putsa fönsterKöket',
])
expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Putsa fönster', description: 'Köket' }),
})
})
test('formulärdata bevaras när skapande av uppgift misslyckas', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([]))
fetchMock.mockResolvedValueOnce(
jsonResponse({ code: 'INVALID_TASK', message: 'Uppgiften är ogiltig.' }, 400),
)
render(<App />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' }))
const title = screen.getByLabelText('Titel')
const description = screen.getByLabelText('Beskrivning (valfri)')
fireEvent.change(title, { target: { value: 'Dammsuga' } })
fireEvent.change(description, { target: { value: 'Bottenvåningen' } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Uppgiften är ogiltig.')
expect(screen.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument()
expect(title).toHaveValue('Dammsuga')
expect(description).toHaveValue('Bottenvåningen')
})
function mockUsersAndTasks(userResponse: unknown, taskResponse: unknown) {
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(userResponse))
fetchMock.mockResolvedValueOnce(jsonResponse(taskResponse))
return fetchMock
}
function mockJsonResponse(body: unknown, status = 200) {
return vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(body, status))
}
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}

View File

@ -1,48 +1,207 @@
import { useEffect, useState } from 'react' import { FormEvent, useEffect, useRef, useState } from 'react'
import TaskBoard from './TaskBoard'
type HealthResponse = { type User = {
status: string id: string
name: string
createdAt: string
} }
type ApiError = {
code?: string
message?: string
}
const ACTIVE_USER_KEY = 'hemhub.activeUserId'
function App() { function App() {
const [backendStatus, setBackendStatus] = useState<string | null>(null) const [users, setUsers] = useState<User[]>([])
const [hasError, setHasError] = useState(false) const [activeUser, setActiveUser] = useState<User | null>(null)
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
const [showCreateUser, setShowCreateUser] = useState(false)
const loadUsers = async () => {
setLoadState('loading')
try {
const response = await fetch('/api/users')
if (!response.ok) {
throw new Error('Kunde inte hämta användare')
}
const loadedUsers = (await response.json()) as User[]
const storedUserId = window.localStorage.getItem(ACTIVE_USER_KEY)
const storedUser = loadedUsers.find((user) => user.id === storedUserId) ?? null
if (storedUserId && !storedUser) {
window.localStorage.removeItem(ACTIVE_USER_KEY)
}
setUsers(loadedUsers)
setActiveUser(storedUser)
setShowCreateUser(loadedUsers.length === 0)
setLoadState('ready')
} catch {
setLoadState('error')
}
}
useEffect(() => { useEffect(() => {
const loadHealth = async () => { void loadUsers()
try {
const response = await fetch('/api/health')
if (!response.ok) {
throw new Error(`Backend svarade med status ${response.status}`)
}
const health = (await response.json()) as HealthResponse
setBackendStatus(health.status)
} catch {
setHasError(true)
}
}
void loadHealth()
}, []) }, [])
let statusMessage = 'Kontrollerar backend…' const selectUser = (user: User) => {
window.localStorage.setItem(ACTIVE_USER_KEY, user.id)
setActiveUser(user)
}
if (hasError) { const logOut = () => {
statusMessage = 'Backend kunde inte nås' window.localStorage.removeItem(ACTIVE_USER_KEY)
} else if (backendStatus) { setActiveUser(null)
statusMessage = `Backend: ${backendStatus}` setShowCreateUser(false)
}
if (loadState === 'loading') {
return <main className="panel">Laddar HemHub</main>
}
if (loadState === 'error') {
return (
<main className="panel">
<h1>HemHub</h1>
<p>Kunde inte ansluta till HemHub.</p>
<button type="button" onClick={() => void loadUsers()}>
Försök igen
</button>
</main>
)
}
if (activeUser) {
return <TaskBoard activeUserName={activeUser.name} onLogOut={logOut} />
}
if (showCreateUser) {
return (
<CreateUserForm
hasExistingUsers={users.length > 0}
onCancel={() => setShowCreateUser(false)}
onCreated={(user) => {
setUsers((currentUsers) => [...currentUsers, user])
selectUser(user)
}}
/>
)
} }
return ( return (
<main> <main className="panel">
<h1>HemHub</h1> <h1>HemHub</h1>
<p>Frontend har startat.</p> <h2>Vem är du?</h2>
<p aria-live="polite">{statusMessage}</p> <div className="user-list">
{users.map((user) => (
<button type="button" key={user.id} onClick={() => selectUser(user)}>
{user.name}
</button>
))}
</div>
<button type="button" className="secondary" onClick={() => setShowCreateUser(true)}>
Skapa användare
</button>
</main>
)
}
type CreateUserFormProps = {
hasExistingUsers: boolean
onCancel: () => void
onCreated: (user: User) => void
}
function CreateUserForm({ hasExistingUsers, onCancel, onCreated }: CreateUserFormProps) {
const [name, setName] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const isSubmittingRef = useRef(false)
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (isSubmittingRef.current) {
return
}
const trimmedName = name.trim()
if (!trimmedName || [...trimmedName].length > 50) {
setError('Namnet måste innehålla mellan 1 och 50 tecken.')
return
}
setError('')
isSubmittingRef.current = true
setIsSubmitting(true)
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmedName }),
})
if (!response.ok) {
const apiError = (await response.json().catch(() => ({}))) as ApiError
if (
apiError.code === 'INVALID_USER_NAME' ||
apiError.code === 'USER_NAME_ALREADY_EXISTS'
) {
setError(apiError.message ?? 'Det gick inte att skapa användaren.')
} else {
setError('Det gick inte att skapa användaren. Försök igen.')
}
return
}
const createdUser = (await response.json()) as User
onCreated(createdUser)
} catch {
setError('Det gick inte att skapa användaren. Försök igen.')
} finally {
isSubmittingRef.current = false
setIsSubmitting(false)
}
}
return (
<main className="panel">
<h1>HemHub</h1>
<h2>Skapa användare</h2>
<form onSubmit={(event) => void submit(event)}>
<label htmlFor="user-name">Namn</label>
<input
id="user-name"
value={name}
disabled={isSubmitting}
onChange={(event) => setName(event.target.value)}
/>
{error && (
<p className="error" role="alert">
{error}
</p>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Skapar…' : 'Skapa användare'}
</button>
{hasExistingUsers && (
<button type="button" className="secondary" disabled={isSubmitting} onClick={onCancel}>
Tillbaka
</button>
)}
</form>
</main> </main>
) )
} }
export default App export default App

249
frontend/src/TaskBoard.tsx Normal file
View File

@ -0,0 +1,249 @@
import { FormEvent, MouseEvent, useEffect, useRef, useState } from 'react'
type TaskStatus = 'WAITING' | 'IN_PROGRESS' | 'COMPLETED'
type Task = {
id: string
title: string
description: string | null
status: TaskStatus
createdAt: string
}
type ApiError = {
message?: string
}
type TaskBoardProps = {
activeUserName: string
onLogOut: () => void
}
const columns: { status: TaskStatus; title: string }[] = [
{ status: 'WAITING', title: 'Väntande' },
{ status: 'IN_PROGRESS', title: 'Pågående' },
{ status: 'COMPLETED', title: 'Klart' },
]
function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
const [tasks, setTasks] = useState<Task[]>([])
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
const [showCreateTask, setShowCreateTask] = useState(false)
const loadTasks = async () => {
setLoadState('loading')
try {
const response = await fetch('/api/tasks')
if (!response.ok) {
throw new Error('Kunde inte hämta uppgifter')
}
setTasks((await response.json()) as Task[])
setLoadState('ready')
} catch {
setLoadState('error')
}
}
useEffect(() => {
void loadTasks()
}, [])
return (
<main className="task-app">
<header className="app-header">
<div>
<p className="eyebrow">HemHub</p>
<h1>Uppgifter</h1>
</div>
<div className="user-controls">
<span>{activeUserName}</span>
<button type="button" className="secondary compact" onClick={onLogOut}>
Logga ut
</button>
</div>
</header>
<div className="board-toolbar">
<div aria-live="polite">
{loadState === 'loading' && 'Laddar uppgifter…'}
{loadState === 'error' && (
<>
Kunde inte hämta uppgifter.
<button type="button" className="link-button" onClick={() => void loadTasks()}>
Försök igen
</button>
</>
)}
</div>
<button type="button" onClick={() => setShowCreateTask(true)}>
Ny uppgift
</button>
</div>
<section className="board" aria-label="Uppgiftsbräda">
{columns.map((column) => (
<section className="board-column" key={column.status} aria-labelledby={column.status}>
<h2 id={column.status}>{column.title}</h2>
<div className="task-list">
{tasks
.filter((task) => task.status === column.status)
.map((task) => (
<article className="task-card" key={task.id}>
<h3>{task.title}</h3>
{task.description && <p>{task.description}</p>}
</article>
))}
</div>
</section>
))}
</section>
{showCreateTask && (
<CreateTaskModal
onClose={() => setShowCreateTask(false)}
onCreated={(task) => {
setTasks((currentTasks) => [...currentTasks, task])
setShowCreateTask(false)
}}
/>
)}
</main>
)
}
type CreateTaskModalProps = {
onClose: () => void
onCreated: (task: Task) => void
}
function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const isSubmittingRef = useRef(false)
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !isSubmittingRef.current) {
onClose()
}
}
window.addEventListener('keydown', closeOnEscape)
return () => window.removeEventListener('keydown', closeOnEscape)
}, [onClose])
const closeFromBackdrop = (event: MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget && !isSubmittingRef.current) {
onClose()
}
}
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (isSubmittingRef.current) {
return
}
const trimmedTitle = title.trim()
const trimmedDescription = description.trim()
if (!trimmedTitle || [...trimmedTitle].length > 100) {
setError('Titeln måste innehålla mellan 1 och 100 tecken.')
return
}
if ([...trimmedDescription].length > 500) {
setError('Beskrivningen får innehålla högst 500 tecken.')
return
}
setError('')
isSubmittingRef.current = true
setIsSubmitting(true)
try {
const response = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: trimmedTitle,
description: trimmedDescription || null,
}),
})
if (!response.ok) {
const apiError = (await response.json().catch(() => ({}))) as ApiError
setError(apiError.message ?? 'Det gick inte att skapa uppgiften. Försök igen.')
return
}
onCreated((await response.json()) as Task)
} catch {
setError('Det gick inte att skapa uppgiften. Försök igen.')
} finally {
isSubmittingRef.current = false
setIsSubmitting(false)
}
}
return (
<div className="modal-backdrop" onMouseDown={closeFromBackdrop}>
<section
className="modal"
role="dialog"
aria-modal="true"
aria-labelledby="create-task-title"
>
<div className="modal-header">
<h2 id="create-task-title">Skapa ny uppgift</h2>
<button
type="button"
className="close-button"
aria-label="Stäng"
disabled={isSubmitting}
onClick={onClose}
>
×
</button>
</div>
<form onSubmit={(event) => void submit(event)}>
<label htmlFor="task-title">Titel</label>
<input
id="task-title"
autoFocus
value={title}
disabled={isSubmitting}
onChange={(event) => setTitle(event.target.value)}
/>
<label htmlFor="task-description">Beskrivning (valfri)</label>
<textarea
id="task-description"
rows={5}
value={description}
disabled={isSubmitting}
onChange={(event) => setDescription(event.target.value)}
/>
{error && (
<p className="error" role="alert">
{error}
</p>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Skapar…' : 'Skapa uppgift'}
</button>
</form>
</section>
</div>
)
}
export default TaskBoard

View File

@ -8,7 +8,7 @@ body {
margin: 0; margin: 0;
} }
main { .panel {
max-width: 40rem; max-width: 40rem;
margin: 6rem auto; margin: 6rem auto;
padding: 2rem; padding: 2rem;
@ -21,3 +21,217 @@ h1 {
margin-top: 0; margin-top: 0;
} }
button,
input,
textarea {
font: inherit;
}
button {
padding: 0.65rem 1rem;
border: 0;
border-radius: 0.4rem;
color: white;
background: #2563eb;
cursor: pointer;
}
button:disabled,
input:disabled,
textarea:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.secondary {
margin-top: 1rem;
color: #1f2937;
background: #e5e7eb;
}
.user-list {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
form {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
input {
box-sizing: border-box;
width: 100%;
padding: 0.6rem;
border: 1px solid #9ca3af;
border-radius: 0.4rem;
}
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-radius: 0.75rem;
background: #e5e7eb;
}
.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 h3,
.task-card p {
margin: 0;
}
.task-card p {
margin-top: 0.5rem;
color: #475569;
white-space: pre-wrap;
}
.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;
}
.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,2 +1,27 @@
import '@testing-library/jest-dom/vitest' import '@testing-library/jest-dom/vitest'
const storedValues = new Map<string, string>()
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: {
get length() {
return storedValues.size
},
clear() {
storedValues.clear()
},
getItem(key: string) {
return storedValues.get(key) ?? null
},
key(index: number) {
return [...storedValues.keys()][index] ?? null
},
removeItem(key: string) {
storedValues.delete(key)
},
setItem(key: string, value: string) {
storedValues.set(key, String(value))
},
} satisfies Storage,
})

View File

@ -11,6 +11,11 @@ export default defineConfig({
}, },
test: { test: {
environment: 'jsdom', environment: 'jsdom',
environmentOptions: {
jsdom: {
url: 'http://localhost',
},
},
setupFiles: './src/test/setup.ts', setupFiles: './src/test/setup.ts',
}, },
}) })