2 Commits

Author SHA1 Message Date
2e62261f49 Merge pull request 'feat: add task points' (#6) from feature/003-task-points into main
Reviewed-on: #6
2026-07-26 17:45:58 +02:00
059d4da921 feat: add task points 2026-07-26 17:43:07 +02:00
16 changed files with 263 additions and 49 deletions

View File

@ -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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
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.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)

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
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

View File

@ -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.

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;
- 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 | 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 |
| 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?

View File

@ -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(<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 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(<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 () => {
@ -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) {

View File

@ -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) => (
<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>}
</article>
))}
@ -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) {
×
</button>
</div>
<form onSubmit={(event) => void submit(event)}>
<form noValidate onSubmit={(event) => void submit(event)}>
<label htmlFor="task-title">Titel</label>
<input
id="task-title"
@ -231,6 +248,22 @@ function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) {
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 && (
<p className="error" role="alert">
{error}

View File

@ -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;