{task.title}
+{task.title}
+ {task.points} p +{task.description}
}diff --git a/README.md b/README.md
index 2a91969..523a332 100644
--- a/README.md
+++ b/README.md
@@ -6,8 +6,8 @@ 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 och lista gemensamma hushållsuppgifter.
diff --git a/backend/src/main/java/se/rubble/hemhub/task/CreateTaskRequest.java b/backend/src/main/java/se/rubble/hemhub/task/CreateTaskRequest.java
index 9efcffc..215e7f7 100644
--- a/backend/src/main/java/se/rubble/hemhub/task/CreateTaskRequest.java
+++ b/backend/src/main/java/se/rubble/hemhub/task/CreateTaskRequest.java
@@ -1,5 +1,14 @@
package se.rubble.hemhub.task;
-public record CreateTaskRequest(String title, String description) {
-}
+import tools.jackson.databind.JsonNode;
+public record CreateTaskRequest(String title, String description, JsonNode points) {
+
+ Integer integerPoints() {
+ if (points == null || !points.isIntegralNumber() || !points.canConvertToInt()) {
+ return null;
+ }
+
+ return points.intValue();
+ }
+}
diff --git a/backend/src/main/java/se/rubble/hemhub/task/Task.java b/backend/src/main/java/se/rubble/hemhub/task/Task.java
index fd1a1cb..b76d883 100644
--- a/backend/src/main/java/se/rubble/hemhub/task/Task.java
+++ b/backend/src/main/java/se/rubble/hemhub/task/Task.java
@@ -27,6 +27,9 @@ class Task {
@Column(nullable = false, length = 20)
private TaskStatus status;
+ @Column(nullable = false)
+ private int points;
+
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@@ -38,11 +41,18 @@ class Task {
String title,
String description,
TaskStatus status,
+ int points,
Instant createdAt) {
+ if (points < 1 || points > 99) {
+ throw new InvalidTaskException(
+ "Poäng måste vara ett heltal mellan 1 och 99.");
+ }
+
this.id = id;
this.title = title;
this.description = description;
this.status = status;
+ this.points = points;
this.createdAt = createdAt;
}
@@ -62,8 +72,11 @@ class Task {
return status;
}
+ int getPoints() {
+ return points;
+ }
+
Instant getCreatedAt() {
return createdAt;
}
}
-
diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskController.java b/backend/src/main/java/se/rubble/hemhub/task/TaskController.java
index 37f9221..07831c0 100644
--- a/backend/src/main/java/se/rubble/hemhub/task/TaskController.java
+++ b/backend/src/main/java/se/rubble/hemhub/task/TaskController.java
@@ -30,7 +30,7 @@ public class TaskController {
public TaskResponse create(@RequestBody(required = false) CreateTaskRequest request) {
return taskService.create(
request == null ? null : request.title(),
- request == null ? null : request.description());
+ request == null ? null : request.description(),
+ request == null ? null : request.integerPoints());
}
}
-
diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskResponse.java b/backend/src/main/java/se/rubble/hemhub/task/TaskResponse.java
index 8712318..51f76ee 100644
--- a/backend/src/main/java/se/rubble/hemhub/task/TaskResponse.java
+++ b/backend/src/main/java/se/rubble/hemhub/task/TaskResponse.java
@@ -8,6 +8,7 @@ public record TaskResponse(
String title,
String description,
TaskStatus status,
+ int points,
Instant createdAt) {
static TaskResponse from(Task task) {
@@ -16,7 +17,7 @@ public record TaskResponse(
task.getTitle(),
task.getDescription(),
task.getStatus(),
+ task.getPoints(),
task.getCreatedAt());
}
}
-
diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskService.java b/backend/src/main/java/se/rubble/hemhub/task/TaskService.java
index e062280..b5ae1fb 100644
--- a/backend/src/main/java/se/rubble/hemhub/task/TaskService.java
+++ b/backend/src/main/java/se/rubble/hemhub/task/TaskService.java
@@ -33,7 +33,10 @@ class TaskService {
}
@Transactional
- TaskResponse create(String requestedTitle, String requestedDescription) {
+ TaskResponse create(
+ String requestedTitle,
+ String requestedDescription,
+ Integer requestedPoints) {
String title = requestedTitle == null ? "" : requestedTitle.trim();
String description = normalizeDescription(requestedDescription);
@@ -47,11 +50,17 @@ class TaskService {
"Beskrivningen får innehålla högst 500 tecken.");
}
+ if (requestedPoints == null) {
+ throw new InvalidTaskException(
+ "Poäng måste vara ett heltal mellan 1 och 99.");
+ }
+
Task task = new Task(
UUID.randomUUID(),
title,
description,
TaskStatus.WAITING,
+ requestedPoints,
Instant.now(clock));
return TaskResponse.from(taskRepository.save(task));
@@ -70,4 +79,3 @@ class TaskService {
return value.codePointCount(0, value.length());
}
}
-
diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties
index 4c260d6..4ea4911 100644
--- a/backend/src/main/resources/application.properties
+++ b/backend/src/main/resources/application.properties
@@ -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
-
diff --git a/backend/src/main/resources/db/migration/V3__add_task_points.sql b/backend/src/main/resources/db/migration/V3__add_task_points.sql
new file mode 100644
index 0000000..f1a1390
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V3__add_task_points.sql
@@ -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);
diff --git a/backend/src/test/java/se/rubble/hemhub/task/TaskApiTest.java b/backend/src/test/java/se/rubble/hemhub/task/TaskApiTest.java
index cc6fdba..3247c7e 100644
--- a/backend/src/test/java/se/rubble/hemhub/task/TaskApiTest.java
+++ b/backend/src/test/java/se/rubble/hemhub/task/TaskApiTest.java
@@ -9,6 +9,7 @@ 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;
@@ -41,7 +42,8 @@ class TaskApiTest {
.content("""
{
"title": " Dammsuga ",
- "description": " Bottenvåningen "
+ "description": " Bottenvåningen ",
+ "points": 7
}
"""))
.andExpect(status().isCreated())
@@ -49,7 +51,12 @@ class TaskApiTest {
.andExpect(jsonPath("$.title").value("Dammsuga"))
.andExpect(jsonPath("$.description").value("Bottenvåningen"))
.andExpect(jsonPath("$.status").value("WAITING"))
+ .andExpect(jsonPath("$.points").value(7))
.andExpect(jsonPath("$.createdAt").isString());
+
+ mockMvc.perform(get("/api/tasks"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$[0].points").value(7));
}
@Test
@@ -57,7 +64,7 @@ class TaskApiTest {
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
- {"title": "Dammsuga", "description": " "}
+ {"title": "Dammsuga", "description": " ", "points": 1}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.description").value((Object) null));
@@ -66,18 +73,58 @@ class TaskApiTest {
@Test
void rejectsBlankAndTooLongTitles() throws Exception {
assertInvalidTask("""
- {"title": " "}
+ {"title": " ", "points": 1}
""");
- assertInvalidTask("{\"title\": \"%s\"}".formatted("a".repeat(101)));
+ assertInvalidTask(
+ "{\"title\": \"%s\", \"points\": 1}".formatted("a".repeat(101)));
}
@Test
void rejectsTooLongDescription() throws Exception {
assertInvalidTask("""
- {"title": "Dammsuga", "description": "%s"}
+ {"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");
@@ -86,17 +133,25 @@ class TaskApiTest {
UUID secondId = UUID.fromString("00000000-0000-0000-0000-000000000002");
UUID newestId = UUID.fromString("00000000-0000-0000-0000-000000000003");
- taskRepository.save(new Task(newestId, "Nyast", null, TaskStatus.WAITING, newer));
- taskRepository.save(new Task(secondId, "Andra", null, TaskStatus.IN_PROGRESS, older));
- taskRepository.save(new Task(firstId, "Första", null, TaskStatus.COMPLETED, older));
+ taskRepository.save(new Task(newestId, "Nyast", null, TaskStatus.WAITING, 3, newer));
+ taskRepository.save(new Task(secondId, "Andra", null, TaskStatus.IN_PROGRESS, 2, older));
+ taskRepository.save(new Task(firstId, "Första", null, TaskStatus.COMPLETED, 1, older));
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"));
}
+ 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)
diff --git a/backend/src/test/java/se/rubble/hemhub/task/TaskTest.java b/backend/src/test/java/se/rubble/hemhub/task/TaskTest.java
new file mode 100644
index 0000000..cbeeee7
--- /dev/null
+++ b/backend/src/test/java/se/rubble/hemhub/task/TaskTest.java
@@ -0,0 +1,27 @@
+package se.rubble.hemhub.task;
+
+import java.time.Instant;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class TaskTest {
+
+ @Test
+ void rejectsPointsOutsideAllowedRange() {
+ assertThrows(InvalidTaskException.class, () -> taskWithPoints(0));
+ assertThrows(InvalidTaskException.class, () -> taskWithPoints(100));
+ }
+
+ private Task taskWithPoints(int points) {
+ return new Task(
+ UUID.randomUUID(),
+ "Dammsuga",
+ null,
+ TaskStatus.WAITING,
+ points,
+ Instant.parse("2026-07-26T12:00:00Z"));
+ }
+}
diff --git a/docs/architecture.md b/docs/architecture.md
index 3ee543b..7ce4974 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -63,9 +63,9 @@ Aktuella endpoints:
### 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.
+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
@@ -76,6 +76,7 @@ Flyway kör migreringarna:
- `V1__create_users.sql`
- `V2__create_tasks.sql`
+- `V3__add_task_points.sql`
Hibernate är konfigurerat med `ddl-auto=validate`; Flyway skapar schemat och
Hibernate validerar entiteterna mot det.
@@ -102,11 +103,13 @@ En uppgift lagras i tabellen `task` med:
- `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;
- `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.
+alltid status `WAITING`. Poängintervallet skyddas i backend och med en
+databasconstraint. Det finns ingen relation mellan uppgifter och användare;
+alla aktiva användare ser samma uppgiftslista.
### Aktiv användare
diff --git a/docs/features/003-task-points.md b/docs/features/003-task-points.md
index e741bf1..db34d9a 100644
--- a/docs/features/003-task-points.md
+++ b/docs/features/003-task-points.md
@@ -2,7 +2,7 @@
## Status
-Planerad.
+Pågående.
## Bakgrund
@@ -315,17 +315,16 @@ Detta innebär att Feature 3 inte behöver migrera verkliga befintliga
utvecklingsposter. En ny databas skapas direkt med det obligatoriska
poängfältet.
-Codex ska kontrollera repositoryts faktiska konfiguration. Om H2 för närvarande
-är filbaserad ska den ändras till in-memory och relevant
-utvecklingsdokumentation ska uppdateras.
+Före Feature 3 var lokal H2 filbaserad. Feature 3 ändrar utvecklingsanslutningen
+till in-memory och uppdaterar utvecklingsdokumentationen i samma ändring.
## Schemahantering och framtida migrering
Att lokal utvecklingsdata inte bevaras innebär inte att framtida
produktionsdata kan återställas vid varje release.
-När HemHub börjar använda en beständig Postgres-databas med data som ska bevaras
-måste schemaändringar hanteras med kontrollerade migreringar.
+När HemHub börjar använda en beständig PostgreSQL-databas med data som ska
+bevaras måste schemaändringar hanteras med kontrollerade migreringar.
Feature 3 behöver inte införa eller färdigställa hela den framtida
produktionsstrategin om den ännu inte finns i repositoryt.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 4d4e703..16a4024 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -38,16 +38,16 @@ Feature 0–2 är klara. Den aktuella applikationen har:
- ett monorepo med separat React/Vite-frontend och Spring Boot-backend;
- centralt lagrade användare och ett lokalt browserval av aktiv användare;
-- gemensamma uppgifter med titel, valfri beskrivning och status;
+- gemensamma uppgifter med titel, valfri beskrivning, status och poäng;
- skapande och listning av uppgifter;
- en bräda med Väntande, Pågående och Klart;
- nya uppgifter som alltid skapas med status `WAITING`.
-Det finns ännu inga poäng, uppgiftstilldelningar, statusändringar,
-drag-and-drop, redigeringar, raderingar, deadlines eller återkommande uppgifter.
+Det finns ännu inga uppgiftstilldelningar, statusändringar, drag-and-drop,
+redigeringar, raderingar, deadlines eller återkommande uppgifter.
Nuvarande användarval är inte autentisering.
-**Feature 3 – Uppgiftspoäng är nästa planerade produktfeature.**
+**Feature 3 – Uppgiftspoäng är pågående.**
## Featureöversikt
@@ -56,7 +56,7 @@ Nuvarande användarval är inte autentisering.
| 0 – Projektgrund | Klar | – | Körbar frontend, backend och lokal API-koppling |
| 1 – Användarval | Klar | 0 | Centrala användare och lokalt aktivt användar-id |
| 2 – Skapa uppgifter | Klar | 0–1 | Gemensamma uppgifter och trekolumnsbräda |
-| 3 – Uppgiftspoäng | Planerad | 2 | Poäng på uppgifter |
+| 3 – Uppgiftspoäng | Pågående | 2 | Poäng på uppgifter |
| 4 – Tilldelning | Planerad | 1–2 | Valfri ansvarig användare |
| 5 – Statusändring | Planerad | 4 | Backendstyrda statusövergångar |
| 6 – Drag-and-drop | Planerad | 5 | Kortflytt via status-API |
@@ -109,7 +109,7 @@ interaktiv brädhantering införs.
### Feature 3 – Uppgiftspoäng
-**Status:** Planerad
+**Status:** Pågående
**Beroenden:** Feature 2
@@ -124,10 +124,9 @@ interaktiv brädhantering införs.
Feature 3 ligger först eftersom poäng blir ett centralt uppgiftsfält som senare
ska kunna redigeras och historikföras.
-**Öppna frågor:**
-
-- exakt poängskala;
-- standardvärde för befintliga uppgifter.
+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
@@ -422,8 +421,6 @@ Nuvarande aktiva användarval är uttryckligen inte autentisering.
## Öppna tvärgående frågor
-- Vilken poängskala ska användas och vilket standardvärde får befintliga
- uppgifter?
- Ska uppgifter raderas permanent eller mjukt?
- Hur ska datum, tider och tidszoner representeras?
- Ska H2 behållas för lokal utveckling efter PostgreSQL-införandet?
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index beef821..22a77a6 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -21,6 +21,7 @@ const tasks = [
title: 'Dammsuga',
description: 'Bottenvåningen',
status: 'WAITING',
+ points: 7,
createdAt: '2026-07-24T10:00:00Z',
},
{
@@ -28,6 +29,7 @@ const tasks = [
title: 'Diska',
description: null,
status: 'IN_PROGRESS',
+ points: 3,
createdAt: '2026-07-24T10:01:00Z',
},
{
@@ -35,6 +37,7 @@ const tasks = [
title: 'Vattna blommor',
description: null,
status: 'COMPLETED',
+ points: 5,
createdAt: '2026-07-24T10:02:00Z',
},
]
@@ -167,12 +170,14 @@ test('brädan visar tre kolumner och grupperar hämtade uppgifter', async () =>
render( {task.description}{task.title}
+ {task.title}
+ {task.points} p
+