feat: add task points #6

Merged
urban merged 1 commits from feature/003-task-points into main 2026-07-26 17:45:59 +02:00
16 changed files with 263 additions and 49 deletions
Showing only changes of commit 059d4da921 - Show all commits

View File

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

View File

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

View File

@ -27,6 +27,9 @@ class Task {
@Column(nullable = false, length = 20) @Column(nullable = false, length = 20)
private TaskStatus status; private TaskStatus status;
@Column(nullable = false)
private int points;
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private Instant createdAt; private Instant createdAt;
@ -38,11 +41,18 @@ class Task {
String title, String title,
String description, String description,
TaskStatus status, TaskStatus status,
int points,
Instant createdAt) { Instant createdAt) {
if (points < 1 || points > 99) {
throw new InvalidTaskException(
"Poäng måste vara ett heltal mellan 1 och 99.");
}
this.id = id; this.id = id;
this.title = title; this.title = title;
this.description = description; this.description = description;
this.status = status; this.status = status;
this.points = points;
this.createdAt = createdAt; this.createdAt = createdAt;
} }
@ -62,8 +72,11 @@ class Task {
return status; return status;
} }
int getPoints() {
return points;
}
Instant getCreatedAt() { Instant getCreatedAt() {
return createdAt; return createdAt;
} }
} }

View File

@ -30,7 +30,7 @@ public class TaskController {
public TaskResponse create(@RequestBody(required = false) CreateTaskRequest request) { public TaskResponse create(@RequestBody(required = false) CreateTaskRequest request) {
return taskService.create( return taskService.create(
request == null ? null : request.title(), request == null ? null : request.title(),
request == null ? null : request.description()); request == null ? null : request.description(),
request == null ? null : request.integerPoints());
} }
} }

View File

@ -8,6 +8,7 @@ public record TaskResponse(
String title, String title,
String description, String description,
TaskStatus status, TaskStatus status,
int points,
Instant createdAt) { Instant createdAt) {
static TaskResponse from(Task task) { static TaskResponse from(Task task) {
@ -16,7 +17,7 @@ public record TaskResponse(
task.getTitle(), task.getTitle(),
task.getDescription(), task.getDescription(),
task.getStatus(), task.getStatus(),
task.getPoints(),
task.getCreatedAt()); task.getCreatedAt());
} }
} }

View File

@ -33,7 +33,10 @@ class TaskService {
} }
@Transactional @Transactional
TaskResponse create(String requestedTitle, String requestedDescription) { TaskResponse create(
String requestedTitle,
String requestedDescription,
Integer requestedPoints) {
String title = requestedTitle == null ? "" : requestedTitle.trim(); String title = requestedTitle == null ? "" : requestedTitle.trim();
String description = normalizeDescription(requestedDescription); String description = normalizeDescription(requestedDescription);
@ -47,11 +50,17 @@ class TaskService {
"Beskrivningen får innehålla högst 500 tecken."); "Beskrivningen får innehålla högst 500 tecken.");
} }
if (requestedPoints == null) {
throw new InvalidTaskException(
"Poäng måste vara ett heltal mellan 1 och 99.");
}
Task task = new Task( Task task = new Task(
UUID.randomUUID(), UUID.randomUUID(),
title, title,
description, description,
TaskStatus.WAITING, TaskStatus.WAITING,
requestedPoints,
Instant.now(clock)); Instant.now(clock));
return TaskResponse.from(taskRepository.save(task)); return TaskResponse.from(taskRepository.save(task));
@ -70,4 +79,3 @@ class TaskService {
return value.codePointCount(0, value.length()); return value.codePointCount(0, value.length());
} }
} }

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext; import org.springframework.web.context.WebApplicationContext;
@ -41,7 +42,8 @@ class TaskApiTest {
.content(""" .content("""
{ {
"title": " Dammsuga ", "title": " Dammsuga ",
"description": " Bottenvåningen " "description": " Bottenvåningen ",
"points": 7
} }
""")) """))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
@ -49,7 +51,12 @@ class TaskApiTest {
.andExpect(jsonPath("$.title").value("Dammsuga")) .andExpect(jsonPath("$.title").value("Dammsuga"))
.andExpect(jsonPath("$.description").value("Bottenvåningen")) .andExpect(jsonPath("$.description").value("Bottenvåningen"))
.andExpect(jsonPath("$.status").value("WAITING")) .andExpect(jsonPath("$.status").value("WAITING"))
.andExpect(jsonPath("$.points").value(7))
.andExpect(jsonPath("$.createdAt").isString()); .andExpect(jsonPath("$.createdAt").isString());
mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].points").value(7));
} }
@Test @Test
@ -57,7 +64,7 @@ class TaskApiTest {
mockMvc.perform(post("/api/tasks") mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(""" .content("""
{"title": "Dammsuga", "description": " "} {"title": "Dammsuga", "description": " ", "points": 1}
""")) """))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
.andExpect(jsonPath("$.description").value((Object) null)); .andExpect(jsonPath("$.description").value((Object) null));
@ -66,18 +73,58 @@ class TaskApiTest {
@Test @Test
void rejectsBlankAndTooLongTitles() throws Exception { void rejectsBlankAndTooLongTitles() throws Exception {
assertInvalidTask(""" assertInvalidTask("""
{"title": " "} {"title": " ", "points": 1}
"""); """);
assertInvalidTask("{\"title\": \"%s\"}".formatted("a".repeat(101))); assertInvalidTask(
"{\"title\": \"%s\", \"points\": 1}".formatted("a".repeat(101)));
} }
@Test @Test
void rejectsTooLongDescription() throws Exception { void rejectsTooLongDescription() throws Exception {
assertInvalidTask(""" assertInvalidTask("""
{"title": "Dammsuga", "description": "%s"} {"title": "Dammsuga", "description": "%s", "points": 1}
""".formatted("a".repeat(501))); """.formatted("a".repeat(501)));
} }
@Test
void acceptsPointBoundaries() throws Exception {
createTaskWithPoints(1)
.andExpect(status().isCreated())
.andExpect(jsonPath("$.points").value(1));
createTaskWithPoints(99)
.andExpect(status().isCreated())
.andExpect(jsonPath("$.points").value(99));
}
@Test
void rejectsMissingNullAndOutOfRangePoints() throws Exception {
assertInvalidTask("""
{"title": "Saknas"}
""");
assertInvalidTask("""
{"title": "Null", "points": null}
""");
assertInvalidTask("""
{"title": "Noll", "points": 0}
""");
assertInvalidTask("""
{"title": "Negativ", "points": -1}
""");
assertInvalidTask("""
{"title": "För stor", "points": 100}
""");
}
@Test
void rejectsNonIntegerPoints() throws Exception {
assertInvalidTask("""
{"title": "Decimal", "points": 1.5}
""");
assertInvalidTask("""
{"title": "Text", "points": "sju"}
""");
}
@Test @Test
void listsTasksOldestFirstWithIdAsTieBreaker() throws Exception { void listsTasksOldestFirstWithIdAsTieBreaker() throws Exception {
Instant older = Instant.parse("2026-07-24T10:00:00Z"); Instant older = Instant.parse("2026-07-24T10:00:00Z");
@ -86,17 +133,25 @@ class TaskApiTest {
UUID secondId = UUID.fromString("00000000-0000-0000-0000-000000000002"); UUID secondId = UUID.fromString("00000000-0000-0000-0000-000000000002");
UUID newestId = UUID.fromString("00000000-0000-0000-0000-000000000003"); UUID newestId = UUID.fromString("00000000-0000-0000-0000-000000000003");
taskRepository.save(new Task(newestId, "Nyast", null, TaskStatus.WAITING, newer)); taskRepository.save(new Task(newestId, "Nyast", null, TaskStatus.WAITING, 3, newer));
taskRepository.save(new Task(secondId, "Andra", null, TaskStatus.IN_PROGRESS, older)); taskRepository.save(new Task(secondId, "Andra", null, TaskStatus.IN_PROGRESS, 2, older));
taskRepository.save(new Task(firstId, "Första", null, TaskStatus.COMPLETED, older)); taskRepository.save(new Task(firstId, "Första", null, TaskStatus.COMPLETED, 1, older));
mockMvc.perform(get("/api/tasks")) mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value("Första")) .andExpect(jsonPath("$[0].title").value("Första"))
.andExpect(jsonPath("$[0].points").value(1))
.andExpect(jsonPath("$[1].title").value("Andra")) .andExpect(jsonPath("$[1].title").value("Andra"))
.andExpect(jsonPath("$[2].title").value("Nyast")); .andExpect(jsonPath("$[2].title").value("Nyast"));
} }
private ResultActions createTaskWithPoints(int points) throws Exception {
return mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\": \"Uppgift %d\", \"points\": %d}"
.formatted(points, points)));
}
private void assertInvalidTask(String body) throws Exception { private void assertInvalidTask(String body) throws Exception {
mockMvc.perform(post("/api/tasks") mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)

View File

@ -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"));
}
}

View File

@ -63,9 +63,9 @@ Aktuella endpoints:
### Databas och migreringar ### Databas och migreringar
Lokal körning använder en filbaserad H2-databas under `backend/data`. Katalogen Lokal körning använder en H2-databas i minnet. Databasen finns under
ignoreras av Git. Automatiska backendtester använder en separat H2-databas i backendprocessens livstid och lokal utvecklingsdata återställs när backend
minnet. startas om. Automatiska backendtester använder en separat H2-databas i minnet.
Båda anslutningarna använder H2:s `MODE=PostgreSQL`, Båda anslutningarna använder H2:s `MODE=PostgreSQL`,
`DATABASE_TO_LOWER=TRUE` och `DEFAULT_NULL_ORDERING=HIGH`. Det är en verifierbar `DATABASE_TO_LOWER=TRUE` och `DEFAULT_NULL_ORDERING=HIGH`. Det är en verifierbar
@ -76,6 +76,7 @@ Flyway kör migreringarna:
- `V1__create_users.sql` - `V1__create_users.sql`
- `V2__create_tasks.sql` - `V2__create_tasks.sql`
- `V3__add_task_points.sql`
Hibernate är konfigurerat med `ddl-auto=validate`; Flyway skapar schemat och Hibernate är konfigurerat med `ddl-auto=validate`; Flyway skapar schemat och
Hibernate validerar entiteterna mot det. Hibernate validerar entiteterna mot det.
@ -102,11 +103,13 @@ En uppgift lagras i tabellen `task` med:
- `title`: obligatorisk titel, högst 100 tecken; - `title`: obligatorisk titel, högst 100 tecken;
- `description`: valfri beskrivning, högst 500 tecken; - `description`: valfri beskrivning, högst 500 tecken;
- `status`: `WAITING`, `IN_PROGRESS` eller `COMPLETED`; - `status`: `WAITING`, `IN_PROGRESS` eller `COMPLETED`;
- `points`: obligatoriskt heltal mellan 1 och 99;
- `created_at`: en `Instant`, lagrad som `TIMESTAMP WITH TIME ZONE`. - `created_at`: en `Instant`, lagrad som `TIMESTAMP WITH TIME ZONE`.
Status lagras som enumens textvärde genom `EnumType.STRING`. Nya uppgifter får Status lagras som enumens textvärde genom `EnumType.STRING`. Nya uppgifter får
alltid status `WAITING`. Det finns ingen relation mellan uppgifter och alltid status `WAITING`. Poängintervallet skyddas i backend och med en
användare; alla aktiva användare ser samma uppgiftslista. databasconstraint. Det finns ingen relation mellan uppgifter och användare;
alla aktiva användare ser samma uppgiftslista.
### Aktiv användare ### Aktiv användare

View File

@ -2,7 +2,7 @@
## Status ## Status
Planerad. Pågående.
## Bakgrund ## 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 utvecklingsposter. En ny databas skapas direkt med det obligatoriska
poängfältet. poängfältet.
Codex ska kontrollera repositoryts faktiska konfiguration. Om H2 för närvarande Före Feature 3 var lokal H2 filbaserad. Feature 3 ändrar utvecklingsanslutningen
är filbaserad ska den ändras till in-memory och relevant till in-memory och uppdaterar utvecklingsdokumentationen i samma ändring.
utvecklingsdokumentation ska uppdateras.
## Schemahantering och framtida migrering ## Schemahantering och framtida migrering
Att lokal utvecklingsdata inte bevaras innebär inte att framtida Att lokal utvecklingsdata inte bevaras innebär inte att framtida
produktionsdata kan återställas vid varje release. produktionsdata kan återställas vid varje release.
När HemHub börjar använda en beständig Postgres-databas med data som ska bevaras När HemHub börjar använda en beständig PostgreSQL-databas med data som ska
måste schemaändringar hanteras med kontrollerade migreringar. bevaras måste schemaändringar hanteras med kontrollerade migreringar.
Feature 3 behöver inte införa eller färdigställa hela den framtida Feature 3 behöver inte införa eller färdigställa hela den framtida
produktionsstrategin om den ännu inte finns i repositoryt. produktionsstrategin om den ännu inte finns i repositoryt.

View File

@ -38,16 +38,16 @@ Feature 02 är klara. Den aktuella applikationen har:
- ett monorepo med separat React/Vite-frontend och Spring Boot-backend; - ett monorepo med separat React/Vite-frontend och Spring Boot-backend;
- centralt lagrade användare och ett lokalt browserval av aktiv användare; - centralt lagrade användare och ett lokalt browserval av aktiv användare;
- gemensamma uppgifter med titel, valfri beskrivning och status; - gemensamma uppgifter med titel, valfri beskrivning, status och poäng;
- skapande och listning av uppgifter; - skapande och listning av uppgifter;
- en bräda med Väntande, Pågående och Klart; - en bräda med Väntande, Pågående och Klart;
- nya uppgifter som alltid skapas med status `WAITING`. - nya uppgifter som alltid skapas med status `WAITING`.
Det finns ännu inga poäng, uppgiftstilldelningar, statusändringar, Det finns ännu inga uppgiftstilldelningar, statusändringar, drag-and-drop,
drag-and-drop, redigeringar, raderingar, deadlines eller återkommande uppgifter. redigeringar, raderingar, deadlines eller återkommande uppgifter.
Nuvarande användarval är inte autentisering. Nuvarande användarval är inte autentisering.
**Feature 3 Uppgiftspoäng är nästa planerade produktfeature.** **Feature 3 Uppgiftspoäng är pågående.**
## Featureöversikt ## Featureöversikt
@ -56,7 +56,7 @@ Nuvarande användarval är inte autentisering.
| 0 Projektgrund | Klar | | Körbar frontend, backend och lokal API-koppling | | 0 Projektgrund | Klar | | Körbar frontend, backend och lokal API-koppling |
| 1 Användarval | Klar | 0 | Centrala användare och lokalt aktivt användar-id | | 1 Användarval | Klar | 0 | Centrala användare och lokalt aktivt användar-id |
| 2 Skapa uppgifter | Klar | 01 | Gemensamma uppgifter och trekolumnsbräda | | 2 Skapa uppgifter | Klar | 01 | Gemensamma uppgifter och trekolumnsbräda |
| 3 Uppgiftspoäng | Planerad | 2 | Poäng på uppgifter | | 3 Uppgiftspoäng | Pågående | 2 | Poäng på uppgifter |
| 4 Tilldelning | Planerad | 12 | Valfri ansvarig användare | | 4 Tilldelning | Planerad | 12 | Valfri ansvarig användare |
| 5 Statusändring | Planerad | 4 | Backendstyrda statusövergångar | | 5 Statusändring | Planerad | 4 | Backendstyrda statusövergångar |
| 6 Drag-and-drop | Planerad | 5 | Kortflytt via status-API | | 6 Drag-and-drop | Planerad | 5 | Kortflytt via status-API |
@ -109,7 +109,7 @@ interaktiv brädhantering införs.
### Feature 3 Uppgiftspoäng ### Feature 3 Uppgiftspoäng
**Status:** Planerad **Status:** Pågående
**Beroenden:** Feature 2 **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 Feature 3 ligger först eftersom poäng blir ett centralt uppgiftsfält som senare
ska kunna redigeras och historikföras. ska kunna redigeras och historikföras.
**Öppna frågor:** Poängskalan är beslutad till alla heltal mellan 1 och 99. V3-migreringen ger
eventuella befintliga uppgifter värdet `1` innan kolumnen görs obligatorisk;
- exakt poängskala; databasen har inget permanent defaultvärde.
- standardvärde för befintliga uppgifter.
### Feature 4 Tilldelning av uppgifter ### Feature 4 Tilldelning av uppgifter
@ -422,8 +421,6 @@ Nuvarande aktiva användarval är uttryckligen inte autentisering.
## Öppna tvärgående frågor ## Öppna tvärgående frågor
- Vilken poängskala ska användas och vilket standardvärde får befintliga
uppgifter?
- Ska uppgifter raderas permanent eller mjukt? - Ska uppgifter raderas permanent eller mjukt?
- Hur ska datum, tider och tidszoner representeras? - Hur ska datum, tider och tidszoner representeras?
- Ska H2 behållas för lokal utveckling efter PostgreSQL-införandet? - Ska H2 behållas för lokal utveckling efter PostgreSQL-införandet?

View File

@ -21,6 +21,7 @@ const tasks = [
title: 'Dammsuga', title: 'Dammsuga',
description: 'Bottenvåningen', description: 'Bottenvåningen',
status: 'WAITING', status: 'WAITING',
points: 7,
createdAt: '2026-07-24T10:00:00Z', createdAt: '2026-07-24T10:00:00Z',
}, },
{ {
@ -28,6 +29,7 @@ const tasks = [
title: 'Diska', title: 'Diska',
description: null, description: null,
status: 'IN_PROGRESS', status: 'IN_PROGRESS',
points: 3,
createdAt: '2026-07-24T10:01:00Z', createdAt: '2026-07-24T10:01:00Z',
}, },
{ {
@ -35,6 +37,7 @@ const tasks = [
title: 'Vattna blommor', title: 'Vattna blommor',
description: null, description: null,
status: 'COMPLETED', status: 'COMPLETED',
points: 5,
createdAt: '2026-07-24T10:02:00Z', createdAt: '2026-07-24T10:02:00Z',
}, },
] ]
@ -167,12 +170,14 @@ test('brädan visar tre kolumner och grupperar hämtade uppgifter', async () =>
render(<App />) render(<App />)
const waiting = await screen.findByRole('region', { name: 'Väntande' }) await screen.findByText('Dammsuga')
const waiting = screen.getByRole('region', { name: 'Väntande' })
const inProgress = screen.getByRole('region', { name: 'Pågående' }) const inProgress = screen.getByRole('region', { name: 'Pågående' })
const completed = screen.getByRole('region', { name: 'Klart' }) const completed = screen.getByRole('region', { name: 'Klart' })
expect(within(waiting).getByText('Dammsuga')).toBeInTheDocument() expect(within(waiting).getByText('Dammsuga')).toBeInTheDocument()
expect(within(waiting).getByText('Bottenvåningen')).toBeInTheDocument() expect(within(waiting).getByText('Bottenvåningen')).toBeInTheDocument()
expect(within(waiting).getByText('7 p')).toBeInTheDocument()
expect(within(inProgress).getByText('Diska')).toBeInTheDocument() expect(within(inProgress).getByText('Diska')).toBeInTheDocument()
expect(within(completed).getByText('Vattna blommor')).toBeInTheDocument() expect(within(completed).getByText('Vattna blommor')).toBeInTheDocument()
expect(screen.queryByText(/Inga uppgifter/i)).not.toBeInTheDocument() expect(screen.queryByText(/Inga uppgifter/i)).not.toBeInTheDocument()
@ -187,6 +192,7 @@ test('Ny uppgift öppnar modalen med fokus i titelfältet', async () => {
expect(screen.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument() expect(screen.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument()
expect(screen.getByLabelText('Titel')).toHaveFocus() expect(screen.getByLabelText('Titel')).toHaveFocus()
expect(screen.getByLabelText('Poäng')).toHaveValue(1)
}) })
test.each([ test.each([
@ -220,6 +226,7 @@ test.each([
fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), { fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), {
target: { value: 'Bottenvåningen' }, target: { value: 'Bottenvåningen' },
}) })
fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: '7' } })
close() close()
@ -229,6 +236,7 @@ test.each([
expect(screen.getByLabelText('Titel')).toHaveValue('') expect(screen.getByLabelText('Titel')).toHaveValue('')
expect(screen.getByLabelText('Beskrivning (valfri)')).toHaveValue('') expect(screen.getByLabelText('Beskrivning (valfri)')).toHaveValue('')
expect(screen.getByLabelText('Poäng')).toHaveValue(1)
}) })
test('en skapad uppgift visas längst ned i Väntande och modalen stängs', async () => { test('en skapad uppgift visas längst ned i Väntande och modalen stängs', async () => {
@ -237,6 +245,7 @@ test('en skapad uppgift visas längst ned i Väntande och modalen stängs', asyn
title: 'Putsa fönster', title: 'Putsa fönster',
description: 'Köket', description: 'Köket',
status: 'WAITING', status: 'WAITING',
points: 7,
createdAt: '2026-07-24T10:03:00Z', createdAt: '2026-07-24T10:03:00Z',
} }
window.localStorage.setItem('hemhub.activeUserId', users[0].id) window.localStorage.setItem('hemhub.activeUserId', users[0].id)
@ -253,6 +262,7 @@ test('en skapad uppgift visas längst ned i Väntande och modalen stängs', asyn
fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), { fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), {
target: { value: ' Köket ' }, target: { value: ' Köket ' },
}) })
fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: '7' } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' })) fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' }))
await waitFor(() => await waitFor(() =>
@ -260,14 +270,39 @@ test('en skapad uppgift visas längst ned i Väntande och modalen stängs', asyn
) )
const waiting = screen.getByRole('region', { name: 'Väntande' }) const waiting = screen.getByRole('region', { name: 'Väntande' })
expect(within(waiting).getAllByRole('article').map((card) => card.textContent)).toEqual([ expect(within(waiting).getAllByRole('article').map((card) => card.textContent)).toEqual([
'DammsugaBottenvåningen', 'Dammsuga7 pBottenvåningen',
'Putsa fönsterKöket', 'Putsa fönster7 pKöket',
]) ])
expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', { expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Putsa fönster', description: 'Köket' }), body: JSON.stringify({ title: 'Putsa fönster', description: 'Köket', points: 7 }),
}) })
fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' }))
expect(screen.getByLabelText('Poäng')).toHaveValue(1)
})
test.each([
{ värde: '', beskrivning: 'tomt' },
{ värde: '0', beskrivning: 'under 1' },
{ värde: '100', beskrivning: 'över 99' },
{ värde: '1.5', beskrivning: 'decimaltal' },
])('ogiltigt poängvärde ($beskrivning) blockerar submit', async ({ värde }) => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = mockUsersAndTasks(users, [])
render(<App />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' }))
fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Dammsuga' } })
fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: värde } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'Poäng måste vara ett heltal mellan 1 och 99.',
)
expect(fetchMock).toHaveBeenCalledTimes(2)
}) })
test('formulärdata bevaras när skapande av uppgift misslyckas', async () => { test('formulärdata bevaras när skapande av uppgift misslyckas', async () => {
@ -285,14 +320,17 @@ test('formulärdata bevaras när skapande av uppgift misslyckas', async () => {
fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' })) fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' }))
const title = screen.getByLabelText('Titel') const title = screen.getByLabelText('Titel')
const description = screen.getByLabelText('Beskrivning (valfri)') const description = screen.getByLabelText('Beskrivning (valfri)')
const points = screen.getByLabelText('Poäng')
fireEvent.change(title, { target: { value: 'Dammsuga' } }) fireEvent.change(title, { target: { value: 'Dammsuga' } })
fireEvent.change(description, { target: { value: 'Bottenvåningen' } }) fireEvent.change(description, { target: { value: 'Bottenvåningen' } })
fireEvent.change(points, { target: { value: '7' } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' })) fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Uppgiften är ogiltig.') expect(await screen.findByRole('alert')).toHaveTextContent('Uppgiften är ogiltig.')
expect(screen.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument() expect(screen.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument()
expect(title).toHaveValue('Dammsuga') expect(title).toHaveValue('Dammsuga')
expect(description).toHaveValue('Bottenvåningen') expect(description).toHaveValue('Bottenvåningen')
expect(points).toHaveValue(7)
}) })
function mockUsersAndTasks(userResponse: unknown, taskResponse: unknown) { function mockUsersAndTasks(userResponse: unknown, taskResponse: unknown) {

View File

@ -7,6 +7,7 @@ type Task = {
title: string title: string
description: string | null description: string | null
status: TaskStatus status: TaskStatus
points: number
createdAt: string createdAt: string
} }
@ -92,7 +93,10 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) {
.filter((task) => task.status === column.status) .filter((task) => task.status === column.status)
.map((task) => ( .map((task) => (
<article className="task-card" key={task.id}> <article className="task-card" key={task.id}>
<h3>{task.title}</h3> <div className="task-card-header">
<h3>{task.title}</h3>
<span className="points-badge">{task.points} p</span>
</div>
{task.description && <p>{task.description}</p>} {task.description && <p>{task.description}</p>}
</article> </article>
))} ))}
@ -122,6 +126,7 @@ type CreateTaskModalProps = {
function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) { function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [points, setPoints] = useState('1')
const [error, setError] = useState('') const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const isSubmittingRef = useRef(false) const isSubmittingRef = useRef(false)
@ -152,6 +157,7 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
const trimmedTitle = title.trim() const trimmedTitle = title.trim()
const trimmedDescription = description.trim() const trimmedDescription = description.trim()
const numericPoints = Number(points)
if (!trimmedTitle || [...trimmedTitle].length > 100) { if (!trimmedTitle || [...trimmedTitle].length > 100) {
setError('Titeln måste innehålla mellan 1 och 100 tecken.') setError('Titeln måste innehålla mellan 1 och 100 tecken.')
@ -163,6 +169,16 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
return return
} }
if (
!points.trim() ||
!Number.isInteger(numericPoints) ||
numericPoints < 1 ||
numericPoints > 99
) {
setError('Poäng måste vara ett heltal mellan 1 och 99.')
return
}
setError('') setError('')
isSubmittingRef.current = true isSubmittingRef.current = true
setIsSubmitting(true) setIsSubmitting(true)
@ -174,6 +190,7 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
body: JSON.stringify({ body: JSON.stringify({
title: trimmedTitle, title: trimmedTitle,
description: trimmedDescription || null, description: trimmedDescription || null,
points: numericPoints,
}), }),
}) })
@ -212,7 +229,7 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
× ×
</button> </button>
</div> </div>
<form onSubmit={(event) => void submit(event)}> <form noValidate onSubmit={(event) => void submit(event)}>
<label htmlFor="task-title">Titel</label> <label htmlFor="task-title">Titel</label>
<input <input
id="task-title" id="task-title"
@ -231,6 +248,22 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
onChange={(event) => setDescription(event.target.value)} onChange={(event) => setDescription(event.target.value)}
/> />
<label htmlFor="task-points">Poäng</label>
<input
id="task-points"
type="number"
min="1"
max="99"
step="1"
value={points}
disabled={isSubmitting}
aria-describedby="task-points-help"
onChange={(event) => setPoints(event.target.value)}
/>
<p id="task-points-help" className="field-help">
199 poäng beroende hur tidskrävande, besvärlig eller viktig uppgiften är.
</p>
{error && ( {error && (
<p className="error" role="alert"> <p className="error" role="alert">
{error} {error}

View File

@ -174,12 +174,36 @@ textarea {
margin: 0; margin: 0;
} }
.task-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.points-badge {
flex: 0 0 auto;
padding: 0.2rem 0.5rem;
border-radius: 999px;
color: #1e3a8a;
background: #dbeafe;
font-size: 0.8rem;
font-weight: 700;
line-height: 1.25;
}
.task-card p { .task-card p {
margin-top: 0.5rem; margin-top: 0.5rem;
color: #475569; color: #475569;
white-space: pre-wrap; white-space: pre-wrap;
} }
.field-help {
margin: -0.25rem 0 0;
color: #64748b;
font-size: 0.85rem;
}
.modal-backdrop { .modal-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;