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() - 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 completed = screen.getByRole('region', { name: 'Klart' }) expect(within(waiting).getByText('Dammsuga')).toBeInTheDocument() expect(within(waiting).getByText('Bottenvåningen')).toBeInTheDocument() + expect(within(waiting).getByText('7 p')).toBeInTheDocument() expect(within(inProgress).getByText('Diska')).toBeInTheDocument() expect(within(completed).getByText('Vattna blommor')).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.getByLabelText('Titel')).toHaveFocus() + expect(screen.getByLabelText('Poäng')).toHaveValue(1) }) test.each([ @@ -220,6 +226,7 @@ test.each([ fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), { target: { value: 'Bottenvåningen' }, }) + fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: '7' } }) close() @@ -229,6 +236,7 @@ test.each([ expect(screen.getByLabelText('Titel')).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 () => { @@ -237,6 +245,7 @@ test('en skapad uppgift visas längst ned i Väntande och modalen stängs', asyn title: 'Putsa fönster', description: 'Köket', status: 'WAITING', + points: 7, createdAt: '2026-07-24T10:03:00Z', } 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)'), { target: { value: ' Köket ' }, }) + fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: '7' } }) fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' })) 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' }) expect(within(waiting).getAllByRole('article').map((card) => card.textContent)).toEqual([ - 'DammsugaBottenvåningen', - 'Putsa fönsterKöket', + 'Dammsuga7 pBottenvåningen', + 'Putsa fönster7 pKöket', ]) expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', { method: 'POST', 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() + + 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 () => { @@ -285,14 +320,17 @@ test('formulärdata bevaras när skapande av uppgift misslyckas', async () => { fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' })) const title = screen.getByLabelText('Titel') const description = screen.getByLabelText('Beskrivning (valfri)') + const points = screen.getByLabelText('Poäng') fireEvent.change(title, { target: { value: 'Dammsuga' } }) fireEvent.change(description, { target: { value: 'Bottenvåningen' } }) + fireEvent.change(points, { target: { value: '7' } }) 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') + expect(points).toHaveValue(7) }) function mockUsersAndTasks(userResponse: unknown, taskResponse: unknown) { diff --git a/frontend/src/TaskBoard.tsx b/frontend/src/TaskBoard.tsx index 720041a..c3d6947 100644 --- a/frontend/src/TaskBoard.tsx +++ b/frontend/src/TaskBoard.tsx @@ -7,6 +7,7 @@ type Task = { title: string description: string | null status: TaskStatus + points: number createdAt: string } @@ -92,7 +93,10 @@ function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) { .filter((task) => task.status === column.status) .map((task) => (
-

{task.title}

+
+

{task.title}

+ {task.points} p +
{task.description &&

{task.description}

}
))} @@ -122,6 +126,7 @@ type CreateTaskModalProps = { function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) { const [title, setTitle] = useState('') const [description, setDescription] = useState('') + const [points, setPoints] = useState('1') const [error, setError] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const isSubmittingRef = useRef(false) @@ -152,6 +157,7 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) { const trimmedTitle = title.trim() const trimmedDescription = description.trim() + const numericPoints = Number(points) if (!trimmedTitle || [...trimmedTitle].length > 100) { setError('Titeln måste innehålla mellan 1 och 100 tecken.') @@ -163,6 +169,16 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) { 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('') isSubmittingRef.current = true setIsSubmitting(true) @@ -174,6 +190,7 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) { body: JSON.stringify({ title: trimmedTitle, description: trimmedDescription || null, + points: numericPoints, }), }) @@ -212,7 +229,7 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) { × -
void submit(event)}> + void submit(event)}> setDescription(event.target.value)} /> + + setPoints(event.target.value)} + /> +

+ 1–99 poäng beroende på hur tidskrävande, besvärlig eller viktig uppgiften är. +

+ {error && (

{error} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 869892b..a9cff6c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -174,12 +174,36 @@ textarea { 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 { margin-top: 0.5rem; color: #475569; white-space: pre-wrap; } +.field-help { + margin: -0.25rem 0 0; + color: #64748b; + font-size: 0.85rem; +} + .modal-backdrop { position: fixed; inset: 0;