~jan0sch/smederee

Showing details for patch 83ea9d658c446f09b65a2a064c963e3ce9c86b90.
2023-02-13 (Mon), 6:59 PM - Jens Grassel - 83ea9d658c446f09b65a2a064c963e3ce9c86b90

Milestones: Basic support for milestones.

Milestones are supposed to be used to organise tickets and project goals.
They can have a due date and their title must be unique within the
repository / project scope.

Like with lables there are still some rough edges but basic functionality is
there.
Summary of changes
8 files added
  • modules/hub/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala
  • modules/hub/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala
  • modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala
  • modules/hub/src/main/scala/de/smederee/tickets/MilestoneRepository.scala
  • modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala
  • modules/hub/src/main/twirl/de/smederee/hub/views/format/formatDate.scala.html
  • modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestone.scala.html
  • modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestones.scala.html
8 files modified with 154 lines added and 29 lines removed
  • CHANGELOG.md with 1 added and 0 removed lines
  • modules/hub/src/it/scala/de/smederee/tickets/Generators.scala with 17 added and 10 removed lines
  • modules/hub/src/main/resources/assets/css/main.css with 17 added and 0 removed lines
  • modules/hub/src/main/resources/messages_en.properties with 23 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/HubServer.scala with 7 added and 4 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala with 69 added and 5 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html with 3 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/Generators.scala with 17 added and 10 removed lines
diff -rN -u old-smederee/CHANGELOG.md new-smederee/CHANGELOG.md
--- old-smederee/CHANGELOG.md	2025-01-31 19:50:42.609163326 +0000
+++ new-smederee/CHANGELOG.md	2025-01-31 19:50:42.613163332 +0000
@@ -23,6 +23,7 @@
 ### Added
 
 - support for labels per repository
+- support for milestones per repository
 
 ## 0.4.0 (2023-01-09)
 
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala	2025-01-31 19:50:42.613163332 +0000
@@ -0,0 +1,299 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import java.time._
+
+import cats.effect._
+import cats.syntax.all._
+import de.smederee.hub.Generators._
+import de.smederee.hub._
+import de.smederee.hub.config.SmedereeHubConfig
+import de.smederee.tickets.Generators._
+import doobie._
+import org.flywaydb.core.Flyway
+import org.http4s.implicits._
+
+import munit._
+
+final class DoobieMilestoneRepositoryTest extends BaseSpec {
+
+  /** Find the milestone ID for the given repository and milestone title.
+    *
+    * @param owner
+    *   The unique ID of the user account that owns the repository.
+    * @param vcsRepoName
+    *   The repository name which must be unique in regard to the owner.
+    * @param title
+    *   The milestone title which must be unique in the repository context.
+    * @return
+    *   An option to the internal database ID.
+    */
+  @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
+  protected def findMilestoneId(
+      owner: UserId,
+      vcsRepoName: VcsRepositoryName,
+      title: MilestoneTitle
+  ): IO[Option[Long]] =
+    connectToDb(configuration).use { con =>
+      for {
+        statement <- IO.delay(
+          con.prepareStatement(
+            """SELECT "milestones".id FROM "milestones" AS "milestones" JOIN "repositories" AS "repositories" ON "milestones".repository = "repositories".id WHERE "repositories".owner = ? AND "repositories".name = ? AND "milestones".title = ?"""
+          )
+        )
+        _      <- IO.delay(statement.setObject(1, owner))
+        _      <- IO.delay(statement.setString(2, vcsRepoName.toString))
+        _      <- IO.delay(statement.setString(3, title.toString))
+        result <- IO.delay(statement.executeQuery)
+        account <- IO.delay {
+          if (result.next()) {
+            Option(result.getLong("id"))
+          } else {
+            None
+          }
+        }
+        _ <- IO(statement.close())
+      } yield account
+    }
+
+  override def beforeEach(context: BeforeEach): Unit = {
+    val dbConfig = configuration.database
+    val flyway: Flyway =
+      Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
+    val _ = flyway.migrate()
+    val _ = flyway.clean()
+    val _ = flyway.migrate()
+  }
+
+  override def afterEach(context: AfterEach): Unit = {
+    val dbConfig = configuration.database
+    val flyway: Flyway =
+      Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
+    val _ = flyway.migrate()
+    val _ = flyway.clean()
+  }
+
+  test("allMilestones must return all milestones") {
+    (genValidAccount.sample, genValidVcsRepository.sample, genMilestones.sample) match {
+      case (Some(account), Some(repository), Some(milestones)) =>
+        val vcsRepository     = repository.copy(owner = account.toVcsRepositoryOwner)
+        val dbConfig          = configuration.database
+        val expectedMilestone = milestones(scala.util.Random.nextInt(milestones.size))
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+        val vcsRepo       = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          _            <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          createdRepos <- vcsRepo.createVcsRepository(vcsRepository)
+          repoId       <- loadVcsRepositoryId(account.uid, vcsRepository.name)
+          createdMilestones <- repoId match {
+            case None         => IO.pure(List.empty)
+            case Some(repoId) => milestones.traverse(milestone => milestoneRepo.createMilestone(repoId)(milestone))
+          }
+          foundMilestones <- repoId match {
+            case None         => IO.pure(List.empty)
+            case Some(repoId) => milestoneRepo.allMilestones(repoId).compile.toList
+          }
+        } yield foundMilestones
+        test.map { foundMilestones =>
+          assert(foundMilestones.size === milestones.size, "Different number of milestones!")
+          foundMilestones.sortBy(_.title).zip(milestones.sortBy(_.title)).map { (found, expected) =>
+            assertEquals(found.copy(id = expected.id), expected)
+          }
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("createMilestone must create the milestone") {
+    (genValidAccount.sample, genValidVcsRepository.sample, genMilestone.sample) match {
+      case (Some(account), Some(repository), Some(milestone)) =>
+        val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
+        val dbConfig      = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+        val vcsRepo       = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          _                 <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          createdRepos      <- vcsRepo.createVcsRepository(vcsRepository)
+          repoId            <- loadVcsRepositoryId(account.uid, vcsRepository.name)
+          createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
+          foundMilestone    <- repoId.traverse(id => milestoneRepo.findMilestone(id)(milestone.title))
+        } yield (createdRepos, repoId, createdMilestones, foundMilestone)
+        test.map { tuple =>
+          val (createdRepos, repoId, createdMilestones, foundMilestone) = tuple
+          assert(createdRepos === 1, "Test vcs repository was not created!")
+          assert(repoId.nonEmpty, "No vcs repository id found!")
+          assert(createdMilestones.exists(_ === 1), "Test milestone was not created!")
+          foundMilestone.getOrElse(None) match {
+            case None                 => fail("Created milestone not found!")
+            case Some(foundMilestone) => assertEquals(foundMilestone, milestone.copy(id = foundMilestone.id))
+          }
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("createMilestone must fail if the milestone name already exists") {
+    (genValidAccount.sample, genValidVcsRepository.sample, genMilestone.sample) match {
+      case (Some(account), Some(repository), Some(milestone)) =>
+        val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
+        val dbConfig      = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+        val vcsRepo       = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          _                 <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          createdRepos      <- vcsRepo.createVcsRepository(vcsRepository)
+          repoId            <- loadVcsRepositoryId(account.uid, vcsRepository.name)
+          createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
+          _                 <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
+        } yield (createdRepos, repoId, createdMilestones)
+        test.attempt.map(r => assert(r.isLeft, "Creating a milestone with a duplicate name must fail!"))
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("deleteMilestone must delete an existing milestone") {
+    (genValidAccount.sample, genValidVcsRepository.sample, genMilestone.sample) match {
+      case (Some(account), Some(repository), Some(milestone)) =>
+        val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
+        val dbConfig      = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+        val vcsRepo       = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          _                 <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          createdRepos      <- vcsRepo.createVcsRepository(vcsRepository)
+          repoId            <- loadVcsRepositoryId(account.uid, vcsRepository.name)
+          createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
+          milestoneId       <- findMilestoneId(account.uid, vcsRepository.name, milestone.title)
+          deletedMilestones <- milestoneRepo.deleteMilestone(milestone.copy(id = milestoneId.flatMap(MilestoneId.from)))
+          foundMilestone    <- repoId.traverse(id => milestoneRepo.findMilestone(id)(milestone.title))
+        } yield (createdRepos, repoId, createdMilestones, deletedMilestones, foundMilestone)
+        test.map { tuple =>
+          val (createdRepos, repoId, createdMilestones, deletedMilestones, foundMilestone) = tuple
+          assert(createdRepos === 1, "Test vcs repository was not created!")
+          assert(repoId.nonEmpty, "No vcs repository id found!")
+          assert(createdMilestones.exists(_ === 1), "Test milestone was not created!")
+          assert(deletedMilestones === 1, "Test milestone was not deleted!")
+          assert(foundMilestone.getOrElse(None).isEmpty, "Test milestone was not deleted!")
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("findMilestone must find existing milestones") {
+    (genValidAccount.sample, genValidVcsRepository.sample, genMilestones.sample) match {
+      case (Some(account), Some(repository), Some(milestones)) =>
+        val vcsRepository     = repository.copy(owner = account.toVcsRepositoryOwner)
+        val dbConfig          = configuration.database
+        val expectedMilestone = milestones(scala.util.Random.nextInt(milestones.size))
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+        val vcsRepo       = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          _            <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          createdRepos <- vcsRepo.createVcsRepository(vcsRepository)
+          repoId       <- loadVcsRepositoryId(account.uid, vcsRepository.name)
+          createdMilestones <- repoId match {
+            case None         => IO.pure(List.empty)
+            case Some(repoId) => milestones.traverse(milestone => milestoneRepo.createMilestone(repoId)(milestone))
+          }
+          foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(expectedMilestone.title))
+        } yield foundMilestone.flatten
+        test.map { foundMilestone =>
+          assertEquals(foundMilestone.map(_.copy(id = expectedMilestone.id)), Option(expectedMilestone))
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("updateMilestone must update an existing milestone") {
+    (genValidAccount.sample, genValidVcsRepository.sample, genMilestone.sample) match {
+      case (Some(account), Some(repository), Some(milestone)) =>
+        val updatedMilestone = milestone.copy(
+          title = MilestoneTitle("updated milestone"),
+          description = Option(MilestoneDescription("I am an updated milestone description...")),
+          dueDate = Option(LocalDate.of(1879, 3, 14))
+        )
+        val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
+        val dbConfig      = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+        val vcsRepo       = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          _                 <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          createdRepos      <- vcsRepo.createVcsRepository(vcsRepository)
+          repoId            <- loadVcsRepositoryId(account.uid, vcsRepository.name)
+          createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
+          milestoneId       <- findMilestoneId(account.uid, vcsRepository.name, milestone.title)
+          updatedMilestones <- milestoneRepo.updateMilestone(
+            updatedMilestone.copy(id = milestoneId.map(MilestoneId.apply))
+          )
+          foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(updatedMilestone.title))
+        } yield (createdRepos, repoId, createdMilestones, updatedMilestones, foundMilestone.flatten)
+        test.map { tuple =>
+          val (createdRepos, repoId, createdMilestones, updatedMilestones, foundMilestone) = tuple
+          assert(createdRepos === 1, "Test vcs repository was not created!")
+          assert(repoId.nonEmpty, "No vcs repository id found!")
+          assert(createdMilestones.exists(_ === 1), "Test milestone was not created!")
+          assert(updatedMilestones === 1, "Test milestone was not updated!")
+          assert(foundMilestone.nonEmpty, "Updated milestone not found!")
+          foundMilestone.map { milestone =>
+            assertEquals(milestone, updatedMilestone.copy(id = milestone.id))
+          }
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("updateMilestone must do nothing if id attribute is empty") {
+    (genValidAccount.sample, genValidVcsRepository.sample, genMilestone.sample) match {
+      case (Some(account), Some(repository), Some(milestone)) =>
+        val updatedMilestone = milestone.copy(
+          id = None,
+          title = MilestoneTitle("updated milestone"),
+          description = Option(MilestoneDescription("I am an updated milestone description...")),
+          dueDate = Option(LocalDate.of(1879, 3, 14))
+        )
+        val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
+        val dbConfig      = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
+        val vcsRepo       = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          _                 <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          createdRepos      <- vcsRepo.createVcsRepository(vcsRepository)
+          repoId            <- loadVcsRepositoryId(account.uid, vcsRepository.name)
+          createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
+          milestoneId       <- findMilestoneId(account.uid, vcsRepository.name, milestone.title)
+          updatedMilestones <- milestoneRepo.updateMilestone(updatedMilestone)
+        } yield (createdRepos, repoId, createdMilestones, updatedMilestones)
+        test.map { tuple =>
+          val (createdRepos, repoId, createdMilestones, updatedMilestones) = tuple
+          assert(createdRepos === 1, "Test vcs repository was not created!")
+          assert(repoId.nonEmpty, "No vcs repository id found!")
+          assert(createdMilestones.exists(_ === 1), "Test milestone was not created!")
+          assert(updatedMilestones === 0, "Milestone with empty id must not be updated!")
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+}
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/tickets/Generators.scala new-smederee/modules/hub/src/it/scala/de/smederee/tickets/Generators.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/tickets/Generators.scala	2025-01-31 19:50:42.609163326 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/tickets/Generators.scala	2025-01-31 19:50:42.613163332 +0000
@@ -17,10 +17,13 @@
 
 package de.smederee.tickets
 
+import java.time._
+
 import org.scalacheck.{ Arbitrary, Gen }
-import java.time.LocalDate
 
 object Generators {
+  val MinimumYear: Int = -4713  // Lowest year supported by PostgreSQL
+  val MaximumYear: Int = 294276 // Highest year supported by PostgreSQL
 
   /** Prepend a zero to a single character hexadecimal code.
     *
@@ -36,6 +39,15 @@
     else
       hexCode
 
+  val genLocalDate: Gen[LocalDate] =
+    for {
+      year  <- Gen.choose(MinimumYear, MaximumYear)
+      month <- Gen.choose(1, 12)
+      day   <- Gen.choose(1, Month.of(month).length(Year.of(year).isLeap))
+    } yield LocalDate.of(year, month, day)
+
+  given Arbitrary[LocalDate] = Arbitrary(genLocalDate)
+
   val genLabelName: Gen[LabelName] =
     Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(LabelName.MaxLength).mkString).map(LabelName.apply)
 
@@ -58,14 +70,6 @@
 
   val genLabels: Gen[List[Label]] = Gen.nonEmptyListOf(genLabel).map(_.distinct)
 
-  val genLocalDate: Gen[LocalDate] =
-    for {
-      year  <- Gen.choose(LocalDate.MIN.getYear(), LocalDate.MAX.getYear())
-      month <- Gen.choose(1, 12)
-      maxDays = LocalDate.of(year, month, 1).lengthOfMonth()
-      day <- Gen.choose(1, maxDays)
-    } yield LocalDate.of(year, month, day)
-
   val genMilestoneTitle: Gen[MilestoneTitle] =
     Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(MilestoneTitle.MaxLength).mkString).map(MilestoneTitle.apply)
 
@@ -74,9 +78,12 @@
 
   val genMilestone: Gen[Milestone] =
     for {
+      id    <- Gen.option(Gen.choose(0L, Long.MaxValue).map(MilestoneId.apply))
       title <- genMilestoneTitle
       due   <- Gen.option(genLocalDate)
       descr <- Gen.option(genMilestoneDescription)
-    } yield Milestone(title, due, descr)
+    } yield Milestone(id, title, descr, due)
+
+  val genMilestones: Gen[List[Milestone]] = Gen.nonEmptyListOf(genMilestone).map(_.distinct)
 
 }
diff -rN -u old-smederee/modules/hub/src/main/resources/assets/css/main.css new-smederee/modules/hub/src/main/resources/assets/css/main.css
--- old-smederee/modules/hub/src/main/resources/assets/css/main.css	2025-01-31 19:50:42.609163326 +0000
+++ new-smederee/modules/hub/src/main/resources/assets/css/main.css	2025-01-31 19:50:42.613163332 +0000
@@ -221,6 +221,23 @@
   vertical-align: middle;
 }
 
+.milestone-description {
+  margin: auto;
+  padding: 0 0.25em;
+  vertical-align: middle;
+}
+
+.milestone-icon {
+  margin: auto;
+  padding: 0.25em 0 0 0;
+}
+
+.milestone-title {
+  margin: auto;
+  padding: 0 0.25em;
+  vertical-align: middle;
+}
+
 .overview-latest-changes {
   font-size: 85%;
 }
diff -rN -u old-smederee/modules/hub/src/main/resources/messages_en.properties new-smederee/modules/hub/src/main/resources/messages_en.properties
--- old-smederee/modules/hub/src/main/resources/messages_en.properties	2025-01-31 19:50:42.609163326 +0000
+++ new-smederee/modules/hub/src/main/resources/messages_en.properties	2025-01-31 19:50:42.613163332 +0000
@@ -64,6 +64,18 @@
 form.login.password.placeholder=Please enter your password here.
 form.login.username=Username
 form.login.username.placeholder=Please enter your username.
+form.milestone.create.button.submit=Create milestone
+form.milestone.due-date=Due date
+form.milestone.due-date.help=You may pick an optional date to indicate until when the milestone is planned to be reached.
+form.milestone.description=Description
+form.milestone.description.help=An optional description of the milestone.
+form.milestone.description.placeholder=description
+form.milestone.title=Title
+form.milestone.title.help=A milestone title can be up to 64 characters long, must not be empty and must be unique in the scope of a project.
+form.milestone.title.placeholder=milestone title
+form.milestone.edit.button.submit=Save milestone
+form.milestone.delete.button.submit=Delete
+form.milestone.delete.i-am-sure=Yes, I'm sure!
 form.signup.button.submit=Sign up for an account
 form.signup.email=Email address
 form.signup.email.help=Please enter your email address.
@@ -207,9 +219,20 @@
 repository.menu.edit=Edit
 repository.menu.files=Files
 repository.menu.labels=Labels
+repository.menu.milestones=Milestones
 repository.menu.overview=Overview
 repository.menu.website=Website
 repository.menu.website.tooltip=Click here to open the project website ({0}) in a new tab or window.
+
+repository.milestone.edit.title=Edit milestone >> {0} <<
+repository.milestone.edit.link=Edit
+repository.milestone.title.date=({0,date,yyyy-MM-dd (E)})
+repository.milestones.add.title=Add a new milestone.
+repository.milestones.edit.title=Manage your repository milestones.
+repository.milestones.view.title=Repository milestones
+repository.milestones.list.empty=There are no milestones defined.
+repository.milestones.list.title={0} milestones.
+
 repository.description.title=Summary:
 repository.description.forked-from=Forked from:
 
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-01-31 19:50:42.613163332 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-01-31 19:50:42.613163332 +0000
@@ -32,6 +32,7 @@
 import de.smederee.hub.config._
 import de.smederee.security._
 import de.smederee.ssh._
+import de.smederee.tickets._
 import doobie._
 import org.http4s._
 import org.http4s.dsl.io._
@@ -43,8 +44,6 @@
 import org.slf4j.LoggerFactory
 import pureconfig._
 import scodec.bits.ByteVector
-import de.smederee.tickets.DoobieLabelRepository
-import de.smederee.tickets.LabelRoutes
 
 /** This is the main entry point for the hub service.
   *
@@ -193,13 +192,16 @@
         darcsWrapper,
         vcsMetadataRepo
       )
-      labelRepo   = new DoobieLabelRepository[IO](transactor)
-      labelRoutes = new LabelRoutes[IO](configuration.service, labelRepo, vcsMetadataRepo)
+      labelRepo       = new DoobieLabelRepository[IO](transactor)
+      labelRoutes     = new LabelRoutes[IO](configuration.service, labelRepo, vcsMetadataRepo)
+      milestoneRepo   = new DoobieMilestoneRepository[IO](transactor)
+      milestoneRoutes = new MilestoneRoutes[IO](configuration.service, milestoneRepo, vcsMetadataRepo)
       protectedRoutesWithFallThrough = authenticationWithFallThrough(
         authenticationRoutes.protectedRoutes <+>
           accountManagementRoutes.protectedRoutes <+>
           signUpRoutes.protectedRoutes <+>
           labelRoutes.protectedRoutes <+>
+          milestoneRoutes.protectedRoutes <+>
           vcsRepoRoutes.protectedRoutes <+>
           landingPages.protectedRoutes
       )
@@ -210,6 +212,7 @@
           accountManagementRoutes.routes <+>
           signUpRoutes.routes <+>
           labelRoutes.routes <+>
+          milestoneRoutes.routes <+>
           vcsRepoRoutes.routes <+>
           landingPages.routes)
       ).orNotFound
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala	2025-01-31 19:50:42.613163332 +0000
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import cats.effect._
+import cats.syntax.all._
+import doobie._
+import doobie.Fragments._
+import doobie.implicits._
+import doobie.postgres.implicits._
+import fs2.Stream
+
+final class DoobieMilestoneRepository[F[_]: Sync](tx: Transactor[F]) extends MilestoneRepository[F] {
+  given Meta[MilestoneDescription] = Meta[String].timap(MilestoneDescription.apply)(_.toString)
+  given Meta[MilestoneId]          = Meta[Long].timap(MilestoneId.apply)(_.toLong)
+  given Meta[MilestoneTitle]       = Meta[String].timap(MilestoneTitle.apply)(_.toString)
+
+  override def allMilestones(vcsRepositoryId: Long): Stream[F, Milestone] =
+    sql"""SELECT id, title, description, due_date FROM "milestones" WHERE repository = $vcsRepositoryId ORDER BY due_date ASC, title ASC"""
+      .query[Milestone]
+      .stream
+      .transact(tx)
+
+  override def createMilestone(vcsRepositoryId: Long)(milestone: Milestone): F[Int] =
+    sql"""INSERT INTO "milestones"
+          (
+            repository,
+            title,
+            due_date,
+            description
+          )
+          VALUES (
+            $vcsRepositoryId,
+            ${milestone.title},
+            ${milestone.dueDate},
+            ${milestone.description}
+          )""".update.run.transact(tx)
+
+  override def deleteMilestone(milestone: Milestone): F[Int] =
+    milestone.id match {
+      case None     => Sync[F].pure(0)
+      case Some(id) => sql"""DELETE FROM "milestones" WHERE id = $id""".update.run.transact(tx)
+    }
+
+  override def findMilestone(vcsRepositoryId: Long)(title: MilestoneTitle): F[Option[Milestone]] =
+    sql"""SELECT id, title, description, due_date FROM "milestones" WHERE repository = $vcsRepositoryId AND title = $title LIMIT 1"""
+      .query[Milestone]
+      .option
+      .transact(tx)
+
+  override def updateMilestone(milestone: Milestone): F[Int] =
+    milestone.id match {
+      case None => Sync[F].pure(0)
+      case Some(id) =>
+        sql"""UPDATE "milestones"
+              SET title = ${milestone.title},
+                due_date = ${milestone.dueDate},
+                description = ${milestone.description}
+              WHERE id = $id""".update.run.transact(tx)
+    }
+
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala	2025-01-31 19:50:42.613163332 +0000
@@ -0,0 +1,126 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import java.time._
+
+import cats._
+import cats.data._
+import cats.syntax.all._
+import de.smederee.hub.forms.FormValidator
+import de.smederee.hub.forms.types._
+
+/** Data container to edit a milestone.
+  *
+  * @param id
+  *   An optional attribute containing the unique internal database ID for the milestone.
+  * @param title
+  *   A title for the milestone, usually a version number, a word or a short phrase that is supposed to be unique within
+  *   a project context.
+  * @param description
+  *   An optional longer description of the milestone.
+  * @param dueDate
+  *   An optional date on which the milestone is supposed to be reached.
+  */
+final case class MilestoneForm(
+    id: Option[MilestoneId],
+    title: MilestoneTitle,
+    description: Option[MilestoneDescription],
+    dueDate: Option[LocalDate]
+)
+
+object MilestoneForm extends FormValidator[MilestoneForm] {
+  val fieldDescription: FormField = FormField("description")
+  val fieldDueDate: FormField     = FormField("due_date")
+  val fieldId: FormField          = FormField("id")
+  val fieldTitle: FormField       = FormField("title")
+
+  /** Create a form for editing a milestone from the given milestone data.
+    *
+    * @param milestone
+    *   The milestone which provides the data for the edit form.
+    * @return
+    *   A milestone form filled with the data from the given milestone.
+    */
+  def fromMilestone(milestone: Milestone): MilestoneForm =
+    MilestoneForm(
+      id = milestone.id,
+      title = milestone.title,
+      description = milestone.description,
+      dueDate = milestone.dueDate
+    )
+
+  override def validate(data: Map[String, String]): ValidatedNec[FormErrors, MilestoneForm] = {
+    val id = data
+      .get(fieldId)
+      .fold(Option.empty[MilestoneId].validNec)(s =>
+        MilestoneId.fromString(s).fold(FormFieldError("Invalid milestone id!").invalidNec)(id => Option(id).validNec)
+      )
+      .leftMap(es => NonEmptyChain.of(Map(fieldId -> es.toList)))
+    val title = data
+      .get(fieldTitle)
+      .map(_.trim) // We strip leading and trailing whitespace!
+      .fold(FormFieldError("No milestone title given!").invalidNec)(s =>
+        MilestoneTitle.from(s).fold(FormFieldError("Invalid milestone title!").invalidNec)(_.validNec)
+      )
+      .leftMap(es => NonEmptyChain.of(Map(fieldTitle -> es.toList)))
+    val description = data
+      .get(fieldDescription)
+      .fold(Option.empty[MilestoneDescription].validNec) { s =>
+        if (s.trim.isEmpty)
+          Option.empty[MilestoneDescription].validNec // Sometimes "empty" strings are sent.
+        else
+          MilestoneDescription
+            .from(s)
+            .fold(FormFieldError("Invalid milestone description!").invalidNec)(descr => Option(descr).validNec)
+      }
+      .leftMap(es => NonEmptyChain.of(Map(fieldDescription -> es.toList)))
+    val dueDate = data
+      .get(fieldDueDate)
+      .fold(Option.empty[LocalDate].validNec) { s =>
+        if (s.trim.isEmpty)
+          Option.empty[LocalDate].validNec
+        else
+          Validated
+            .catchNonFatal(LocalDate.parse(s))
+            .map(date => Option(date))
+      }
+      .leftMap(_ => NonEmptyChain.of(Map(fieldDueDate -> List(FormFieldError("Invalid milestone due date!")))))
+    (id, title, description, dueDate).mapN { case (id, title, description, dueDate) =>
+      MilestoneForm(id, title, description, dueDate)
+    }
+  }
+
+  extension (form: MilestoneForm) {
+
+    /** Convert the form class into a stringified map which is used as underlying data type for form handling in the
+      * twirl templating library.
+      *
+      * @return
+      *   A stringified map containing the data of the form.
+      */
+    def toMap: Map[String, String] =
+      Map(
+        MilestoneForm.fieldId.toString          -> form.id.map(_.toString).getOrElse(""),
+        MilestoneForm.fieldTitle.toString       -> form.title.toString,
+        MilestoneForm.fieldDescription.toString -> form.description.map(_.toString).getOrElse(""),
+        MilestoneForm.fieldDueDate.toString     -> form.dueDate.map(_.toString).getOrElse("")
+      )
+  }
+
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRepository.scala	2025-01-31 19:50:42.613163332 +0000
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import fs2.Stream
+
+/** The base class that defines the needed functionality to handle milestones within a database.
+  *
+  * @tparam F
+  *   A higher kinded type which wraps the actual return values.
+  */
+abstract class MilestoneRepository[F[_]] {
+
+  /** Return all milestones associated with the given repository.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry for which all milestones shall be returned.
+    * @return
+    *   A stream of milestones associated with the vcs repository which may be empty.
+    */
+  def allMilestones(vcsRepositoryId: Long): Stream[F, Milestone]
+
+  /** Create a database entry for the given milestone definition.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry to which the milestone belongs.
+    * @param milestone
+    *   The milestone definition that shall be written to the database.
+    * @return
+    *   The number of affected database rows.
+    */
+  def createMilestone(vcsRepositoryId: Long)(milestone: Milestone): F[Int]
+
+  /** Delete the milestone from the database.
+    *
+    * @param milestone
+    *   The milestone definition that shall be deleted from the database.
+    * @return
+    *   The number of affected database rows.
+    */
+  def deleteMilestone(milestone: Milestone): F[Int]
+
+  /** Find the milestone with the given title for the given vcs repository.
+    *
+    * @param vcsRepositoryId
+    *   The unique internal ID of a vcs repository metadata entry to which the milestone belongs.
+    * @param title
+    *   The title of the milestone which is must be unique in the context of the repository.
+    * @return
+    *   An option to the found milestone.
+    */
+  def findMilestone(vcsRepositoryId: Long)(title: MilestoneTitle): F[Option[Milestone]]
+
+  /** Update the database entry for the given milestone.
+    *
+    * @param milestone
+    *   The milestone definition that shall be updated within the database.
+    * @return
+    *   The number of affected database rows.
+    */
+  def updateMilestone(milestone: Milestone): F[Int]
+
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala	2025-01-31 19:50:42.613163332 +0000
@@ -0,0 +1,469 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import cats._
+import cats.data._
+import cats.effect._
+import cats.syntax.all._
+import de.smederee.html.LinkTools._
+import de.smederee.hub.RequestHelpers.instances.given
+import de.smederee.hub._
+import de.smederee.hub.config._
+import de.smederee.hub.forms.types._
+import org.http4s._
+import org.http4s.dsl.Http4sDsl
+import org.http4s.dsl.impl._
+import org.http4s.headers.Location
+import org.http4s.implicits._
+import org.http4s.twirl.TwirlInstances._
+import org.slf4j.LoggerFactory
+
+/** Routes for managing milestones (basically CRUD functionality).
+  *
+  * @param configuration
+  *   The hub service configuration.
+  * @param milestoneRepo
+  *   A repository for handling database operations for milestones.
+  * @param vcsMetadataRepo
+  *   A repository for handling database operations regarding our vcs repositories and their metadata.
+  * @tparam F
+  *   A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync.
+  */
+final class MilestoneRoutes[F[_]: Async](
+    configuration: ServiceConfig,
+    milestoneRepo: MilestoneRepository[F],
+    vcsMetadataRepo: VcsMetadataRepository[F]
+) extends Http4sDsl[F] {
+  private val log = LoggerFactory.getLogger(getClass)
+
+  val linkConfig = configuration.external
+
+  /** Logic for rendering a list of all milestones for a repository and optionally management functionality.
+    *
+    * @param csrf
+    *   An optional CSRF-Token that shall be used.
+    * @param user
+    *   An optional user account for whom the list of milestones shall be rendered.
+    * @param repositoryOwnerName
+    *   The username of the account who owns the repository.
+    * @param repositoryName
+    *   The name of the repository.
+    * @return
+    *   An HTTP response containing the rendered HTML.
+    */
+  private def doShowMilestones(
+      csrf: Option[CsrfToken]
+  )(user: Option[Account])(repositoryOwnerName: Username)(repositoryName: VcsRepositoryName): F[Response[F]] =
+    for {
+      repoAndId <- loadRepo(user)(repositoryOwnerName, repositoryName)
+      resp <- repoAndId match {
+        case Some((repo, repoId)) =>
+          for {
+            milestones <- milestoneRepo.allMilestones(repoId).compile.toList
+            repositoryBaseUri <- Sync[F].delay(
+              linkConfig.createFullUri(
+                Uri(path =
+                  Uri.Path(
+                    Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString))
+                  )
+                )
+              )
+            )
+            resp <- Ok(
+              views.html.tickets.editMilestones()(
+                repositoryBaseUri.addSegment("milestones"),
+                csrf,
+                milestones,
+                repositoryBaseUri,
+                "Manage your repository milestones.".some,
+                user,
+                repo
+              )()
+            )
+          } yield resp
+        case _ => NotFound()
+      }
+    } yield resp
+
+  /** Load the repository metadata with the given owner and name from the database and return it and its primary key id
+    * if the repository exists and is readable by the given user account.
+    *
+    * @param currentUser
+    *   The user account that is requesting access to the repository or None for a guest user.
+    * @param repositoryOwnerName
+    *   The name of the account that owns the repository.
+    * @param repositoryName
+    *   The name of the repository. A repository name must start with a letter or number and must contain only
+    *   alphanumeric ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long.
+    * @return
+    *   An option to a tuple holding the [[VcsRepository]] and its primary key id.
+    */
+  private def loadRepo(
+      currentUser: Option[Account]
+  )(repositoryOwnerName: Username, repositoryName: VcsRepositoryName): F[Option[(VcsRepository, Long)]] =
+    for {
+      owner <- vcsMetadataRepo.findVcsRepositoryOwner(repositoryOwnerName)
+      loadedRepo <- owner match {
+        case None => Sync[F].pure(None)
+        case Some(owner) =>
+          (
+            vcsMetadataRepo.findVcsRepository(owner, repositoryName),
+            vcsMetadataRepo.findVcsRepositoryId(owner, repositoryName)
+          ).mapN {
+            case (Some(repo), Some(repoId)) => (repo, repoId).some
+            case _                          => None
+          }
+      }
+      // TODO Replace with whatever we implement as proper permission model. ;-)
+      repoAndId = currentUser match {
+        case None => loadedRepo.filter(tuple => tuple._1.isPrivate === false)
+        case Some(user) =>
+          loadedRepo.filter(tuple => tuple._1.isPrivate === false || tuple._1.owner === user.toVcsRepositoryOwner)
+      }
+    } yield repoAndId
+
+  private val addMilestone: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ POST -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
+          repositoryName
+        ) / "milestones" as user =>
+      ar.req.decodeStrict[F, UrlForm] { urlForm =>
+        for {
+          csrf      <- Sync[F].delay(ar.req.getCsrfToken)
+          repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
+          resp <- repoAndId match {
+            case Some(repo, repoId) =>
+              for {
+                _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add milestones!"))
+                formData <- Sync[F].delay {
+                  urlForm.values.map { t =>
+                    val (key, values) = t
+                    (
+                      key,
+                      values.headOption.getOrElse("")
+                    ) // Pick the first value (a field might get submitted multiple times)!
+                  }
+                }
+                form       <- Sync[F].delay(MilestoneForm.validate(formData))
+                milestones <- repoAndId.traverse(tuple => milestoneRepo.allMilestones(tuple._2).compile.toList)
+                repositoryBaseUri <- Sync[F].delay(
+                  linkConfig.createFullUri(
+                    Uri(path =
+                      Uri.Path(
+                        Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString))
+                      )
+                    )
+                  )
+                )
+                resp <- form match {
+                  case Validated.Invalid(errors) =>
+                    BadRequest(
+                      views.html.tickets.editMilestones()(
+                        repositoryBaseUri.addSegment("milestones"),
+                        csrf,
+                        milestones.getOrElse(List.empty),
+                        repositoryBaseUri,
+                        "Manage your repository milestones.".some,
+                        user.some,
+                        repo
+                      )(formData, FormErrors.fromNec(errors))
+                    )
+                  case Validated.Valid(milestoneData) =>
+                    val milestone =
+                      Milestone(None, milestoneData.title, milestoneData.description, milestoneData.dueDate)
+                    for {
+                      checkDuplicate <- milestoneRepo.findMilestone(repoId)(milestoneData.title)
+                      resp <- checkDuplicate match {
+                        case None =>
+                          milestoneRepo.createMilestone(repoId)(milestone) *> SeeOther(
+                            Location(repositoryBaseUri.addSegment("milestones"))
+                          )
+                        case Some(_) =>
+                          BadRequest(
+                            views.html.tickets.editMilestones()(
+                              repositoryBaseUri.addSegment("milestones"),
+                              csrf,
+                              milestones.getOrElse(List.empty),
+                              repositoryBaseUri,
+                              "Manage your repository milestones.".some,
+                              user.some,
+                              repo
+                            )(
+                              formData,
+                              Map(
+                                MilestoneForm.fieldTitle -> List(
+                                  FormFieldError("A milestone with that name already exists!")
+                                )
+                              )
+                            )
+                          )
+                      }
+                    } yield resp
+                }
+              } yield resp
+            case _ => NotFound()
+          }
+        } yield resp
+      }
+  }
+
+  private val deleteMilestone: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ POST -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
+          repositoryName
+        ) / "milestone" / MilestoneTitlePathParameter(milestoneTitle) / "delete" as user =>
+      ar.req.decodeStrict[F, UrlForm] { urlForm =>
+        for {
+          csrf      <- Sync[F].delay(ar.req.getCsrfToken)
+          repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
+          resp <- repoAndId match {
+            case Some(repo, repoId) =>
+              for {
+                _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add milestones!"))
+                milestone <- milestoneRepo.findMilestone(repoId)(milestoneTitle)
+                resp <- milestone match {
+                  case Some(milestone) =>
+                    for {
+                      formData <- Sync[F].delay {
+                        urlForm.values.map { t =>
+                          val (key, values) = t
+                          (
+                            key,
+                            values.headOption.getOrElse("")
+                          ) // Pick the first value (a field might get submitted multiple times)!
+                        }
+                      }
+                      repositoryBaseUri <- Sync[F].delay(
+                        linkConfig.createFullUri(
+                          Uri(path =
+                            Uri.Path(
+                              Vector(
+                                Uri.Path.Segment(s"~$repositoryOwnerName"),
+                                Uri.Path.Segment(repositoryName.toString)
+                              )
+                            )
+                          )
+                        )
+                      )
+                      userIsSure <- Sync[F].delay(formData.get("i-am-sure").exists(_ === "yes"))
+                      milestoneIdMatches <- Sync[F].delay(
+                        formData
+                          .get(MilestoneForm.fieldId)
+                          .flatMap(MilestoneId.fromString)
+                          .exists(id => milestone.id.exists(_ === id))
+                      )
+                      milestoneTitleMatches <- Sync[F].delay(
+                        formData.get(MilestoneForm.fieldTitle).flatMap(MilestoneTitle.from).exists(_ === milestoneTitle)
+                      )
+                      resp <- (milestoneIdMatches && milestoneTitleMatches && userIsSure) match {
+                        case false => BadRequest("Invalid form data!")
+                        case true =>
+                          milestoneRepo.deleteMilestone(milestone) *> SeeOther(
+                            Location(repositoryBaseUri.addSegment("milestones"))
+                          )
+                      }
+                    } yield resp
+                  case _ => NotFound("Milestone not found!")
+                }
+              } yield resp
+            case _ => NotFound("Repository not found!")
+          }
+        } yield resp
+      }
+  }
+
+  private val editMilestone: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ POST -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
+          repositoryName
+        ) / "milestone" / MilestoneTitlePathParameter(milestoneTitle) as user =>
+      ar.req.decodeStrict[F, UrlForm] { urlForm =>
+        for {
+          csrf      <- Sync[F].delay(ar.req.getCsrfToken)
+          repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
+          milestone <- repoAndId match {
+            case Some((_, repoId)) => milestoneRepo.findMilestone(repoId)(milestoneTitle)
+            case _                 => Sync[F].delay(None)
+          }
+          resp <- (repoAndId, milestone) match {
+            case (Some(repo, repoId), Some(milestone)) =>
+              for {
+                _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add milestones!"))
+                repositoryBaseUri <- Sync[F].delay(
+                  linkConfig.createFullUri(
+                    Uri(path =
+                      Uri.Path(
+                        Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString))
+                      )
+                    )
+                  )
+                )
+                actionUri <- Sync[F].delay(
+                  repositoryBaseUri.addSegment("milestone").addSegment(milestone.title.toString)
+                )
+                formData <- Sync[F].delay {
+                  urlForm.values.map { t =>
+                    val (key, values) = t
+                    (
+                      key,
+                      values.headOption.getOrElse("")
+                    ) // Pick the first value (a field might get submitted multiple times)!
+                  }
+                }
+                milestoneIdMatches <- Sync[F].delay(
+                  formData
+                    .get(MilestoneForm.fieldId)
+                    .flatMap(MilestoneId.fromString)
+                    .exists(id => milestone.id.exists(_ === id)) match {
+                    case false =>
+                      NonEmptyChain
+                        .of(Map(MilestoneForm.fieldGlobal -> List(FormFieldError("Milestone ID does not match!"))))
+                        .invalidNec
+                    case true => milestone.id.validNec
+                  }
+                )
+                form <- Sync[F].delay(MilestoneForm.validate(formData))
+                resp <- form match {
+                  case Validated.Invalid(errors) =>
+                    BadRequest(
+                      views.html.tickets
+                        .editMilestone()(
+                          actionUri,
+                          csrf,
+                          milestone,
+                          repositoryBaseUri,
+                          s"Edit milestone ${milestone.title}".some,
+                          user,
+                          repo
+                        )(
+                          formData.toMap,
+                          FormErrors.fromNec(errors)
+                        )
+                    )
+                  case Validated.Valid(milestoneData) =>
+                    val updatedMilestone =
+                      milestone.copy(
+                        title = milestoneData.title,
+                        description = milestoneData.description,
+                        dueDate = milestoneData.dueDate
+                      )
+                    for {
+                      checkDuplicate <- milestoneRepo.findMilestone(repoId)(updatedMilestone.title)
+                      resp <- checkDuplicate.filterNot(_.id === updatedMilestone.id) match {
+                        case None =>
+                          milestoneRepo.updateMilestone(updatedMilestone) *> SeeOther(
+                            Location(repositoryBaseUri.addSegment("milestones"))
+                          )
+                        case Some(_) =>
+                          BadRequest(
+                            views.html.tickets.editMilestone()(
+                              actionUri,
+                              csrf,
+                              milestone,
+                              repositoryBaseUri,
+                              s"Edit milestone ${milestone.title}".some,
+                              user,
+                              repo
+                            )(
+                              formData,
+                              Map(
+                                MilestoneForm.fieldTitle -> List(
+                                  FormFieldError("A milestone with that name already exists!")
+                                )
+                              )
+                            )
+                          )
+                      }
+                    } yield resp
+                }
+              } yield resp
+            case _ => NotFound()
+          }
+        } yield resp
+      }
+  }
+
+  private val showEditMilestoneForm: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
+          repositoryName
+        ) / "milestone" / MilestoneTitlePathParameter(milestoneTitle) / "edit" as user =>
+      for {
+        csrf      <- Sync[F].delay(ar.req.getCsrfToken)
+        repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
+        milestone <- repoAndId match {
+          case Some((_, repoId)) => milestoneRepo.findMilestone(repoId)(milestoneTitle)
+          case _                 => Sync[F].delay(None)
+        }
+        resp <- (repoAndId, milestone) match {
+          case (Some(repo, repoId), Some(milestone)) =>
+            for {
+              repositoryBaseUri <- Sync[F].delay(
+                linkConfig.createFullUri(
+                  Uri(path =
+                    Uri.Path(
+                      Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString))
+                    )
+                  )
+                )
+              )
+              actionUri <- Sync[F].delay(repositoryBaseUri.addSegment("milestone").addSegment(milestone.title.toString))
+              formData  <- Sync[F].delay(MilestoneForm.fromMilestone(milestone))
+              resp <- Ok(
+                views.html.tickets
+                  .editMilestone()(
+                    actionUri,
+                    csrf,
+                    milestone,
+                    repositoryBaseUri,
+                    s"Edit milestone ${milestone.title}".some,
+                    user,
+                    repo
+                  )(
+                    formData.toMap
+                  )
+              )
+            } yield resp
+          case _ => NotFound()
+        }
+      } yield resp
+  }
+
+  private val showEditMilestonesPage: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
+          repositoryName
+        ) / "milestones" as user =>
+      for {
+        csrf <- Sync[F].delay(ar.req.getCsrfToken)
+        resp <- doShowMilestones(csrf)(user.some)(repositoryOwnerName)(repositoryName)
+      } yield resp
+  }
+
+  private val showMilestonesForGuests: HttpRoutes[F] = HttpRoutes.of {
+    case req @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
+          repositoryName
+        ) / "milestones" =>
+      for {
+        csrf <- Sync[F].delay(req.getCsrfToken)
+        resp <- doShowMilestones(csrf)(None)(repositoryOwnerName)(repositoryName)
+      } yield resp
+  }
+
+  val protectedRoutes =
+    addMilestone <+> deleteMilestone <+> editMilestone <+> showEditMilestoneForm <+> showEditMilestonesPage
+
+  val routes = showMilestonesForGuests
+
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala	2025-01-31 19:50:42.613163332 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala	2025-01-31 19:50:42.613163332 +0000
@@ -22,12 +22,61 @@
 import cats._
 import cats.syntax.all._
 
+import scala.util.matching.Regex
+
+opaque type MilestoneId = Long
+object MilestoneId {
+  given Eq[MilestoneId] = Eq.fromUniversalEquals
+
+  val Format: Regex = "^-?\\d+$".r
+
+  /** Create an instance of MilestoneId from the given Long type.
+    *
+    * @param source
+    *   An instance of type Long which will be returned as a MilestoneId.
+    * @return
+    *   The appropriate instance of MilestoneId.
+    */
+  def apply(source: Long): MilestoneId = source
+
+  /** Try to create an instance of MilestoneId from the given Long.
+    *
+    * @param source
+    *   A Long that should fulfil the requirements to be converted into a MilestoneId.
+    * @return
+    *   An option to the successfully converted MilestoneId.
+    */
+  def from(source: Long): Option[MilestoneId] = Option(source)
+
+  /** Try to create an instance of MilestoneId from the given String.
+    *
+    * @param source
+    *   A string that should fulfil the requirements to be converted into a MilestoneId.
+    * @return
+    *   An option to the successfully converted MilestoneId.
+    */
+  def fromString(source: String): Option[MilestoneId] =
+    Option(source).filter(Format.matches).map(_.toLong).flatMap(from)
+
+  extension (id: MilestoneId) {
+    def toLong: Long = id
+  }
+}
+
+/** Extractor to retrieve an MilestoneId from a path parameter.
+  */
+object MilestoneIdPathParameter {
+  def unapply(str: String): Option[MilestoneId] = Option(str).flatMap(MilestoneId.fromString)
+}
+
 /** A title for a milestone, usually a version number, a word or a short phrase that is supposed to be unique within a
   * project context. It must not be empty and not exceed 64 characters in length.
   */
 opaque type MilestoneTitle = String
 object MilestoneTitle {
-  given Eq[MilestoneTitle] = Eq.fromUniversalEquals
+  given Eq[MilestoneTitle]       = Eq.fromUniversalEquals
+  given Ordering[MilestoneTitle] = (x: MilestoneTitle, y: MilestoneTitle) => x.compareTo(y)
+  given Order[MilestoneTitle]    = Order.fromOrdering[MilestoneTitle]
 
   val MaxLength: Int = 64
 
@@ -52,6 +101,12 @@
 
 }
 
+/** Extractor to retrieve an MilestoneTitle from a path parameter.
+  */
+object MilestoneTitlePathParameter {
+  def unapply(str: String): Option[MilestoneTitle] = Option(str).flatMap(MilestoneTitle.from)
+}
+
 /** A longer detailed description of a project milestone which must not be empty.
   */
 opaque type MilestoneDescription = String
@@ -80,21 +135,30 @@
 
 /** A milestone can be used to organise tickets and progress inside a project.
   *
+  * @param id
+  *   An optional attribute containing the unique internal database ID for the milestone.
   * @param title
   *   A title for the milestone, usually a version number, a word or a short phrase that is supposed to be unique within
   *   a project context.
-  * @param dueDate
-  *   An optional date on which the milestone is supposed to be reached.
   * @param description
   *   An optional longer description of the milestone.
+  * @param dueDate
+  *   An optional date on which the milestone is supposed to be reached.
   */
-final case class Milestone(title: MilestoneTitle, dueDate: Option[LocalDate], description: Option[MilestoneDescription])
+final case class Milestone(
+    id: Option[MilestoneId],
+    title: MilestoneTitle,
+    description: Option[MilestoneDescription],
+    dueDate: Option[LocalDate]
+)
 
 object Milestone {
 
   given Eq[LocalDate] = Eq.instance((a, b) => a.compareTo(b) === 0)
 
   given Eq[Milestone] =
-    Eq.instance((a, b) => a.title === b.title && a.dueDate === b.dueDate && a.description === b.description)
+    Eq.instance((a, b) =>
+      a.id === b.id && a.title === b.title && a.dueDate === b.dueDate && a.description === b.description
+    )
 
 }
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/format/formatDate.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/format/formatDate.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/format/formatDate.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/format/formatDate.scala.html	2025-01-31 19:50:42.617163338 +0000
@@ -0,0 +1,6 @@
+@import java.time._
+@import java.time.format._
+@import java.util.Locale
+
+@(date: LocalDate, style: FormatStyle = FormatStyle.MEDIUM)(implicit locale: Locale)
+(@DateTimeFormatter.ofLocalizedDate(style).withLocale(locale).format(date))
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html	2025-01-31 19:50:42.613163332 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html	2025-01-31 19:50:42.613163332 +0000
@@ -18,6 +18,9 @@
     @defining(repositoryBaseUri.addSegment("labels")) { uri =>
     <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("tag") @Messages("repository.menu.labels")</a></li>
     }
+    @defining(repositoryBaseUri.addSegment("milestones")) { uri =>
+    <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("flag") @Messages("repository.menu.milestones")</a></li>
+    }
     @if(activeUri.exists(uri => uri === repositoryBaseUri || uri === repositoryBaseUri.addSegment("edit") || uri === repositoryBaseUri.addSegment("delete"))) {
       @if(user.exists(_.uid === vcsRepository.owner.uid)) {
       @defining(repositoryBaseUri.addSegment("edit")) { uri =>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestone.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestone.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestone.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestone.scala.html	2025-01-31 19:50:42.617163338 +0000
@@ -0,0 +1,87 @@
+@import de.smederee.hub.views.html.showRepositoryMenu
+@import de.smederee.tickets.MilestoneForm._
+@import de.smederee.tickets._
+
+@(baseUri: Uri = Uri(path = Uri.Path.Root),
+  lang: LanguageCode = LanguageCode("en")
+)(action: Uri,
+  csrf: Option[CsrfToken] = None,
+  milestone: Milestone,
+  repositoryBaseUri: Uri,
+  title: Option[String] = None,
+  user: Account,
+  vcsRepository: VcsRepository
+)(formData: Map[String, String] = Map.empty,
+  formErrors: FormErrors = FormErrors.empty
+)
+@main(baseUri, lang)()(csrf, title, user.some) {
+@defining(lang.toLocale) { implicit locale =>
+<div class="content">
+  <div class="pure-g">
+    <div class="pure-u-1">
+      <div class="l-box-left-right">
+        <h2><a href="@{baseUri.addSegment(s"~${vcsRepository.owner.name}")}">~@vcsRepository.owner.name</a>/@vcsRepository.name</h2>
+        @showRepositoryMenu(baseUri)(repositoryBaseUri.addSegment("milestones").some, repositoryBaseUri, user.some, vcsRepository)
+        <div class="repo-summary-description">
+          @Messages("repository.milestones.edit.title")
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="pure-g">
+    @if(user.uid === vcsRepository.owner.uid) {
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+        <h4>@Messages("repository.milestone.edit.title", milestone.title)</h4>
+        <div class="form-errors">
+          @formErrors.get(fieldGlobal).map { es =>
+            @for(error <- es) {
+              <p class="alert alert-error">
+                <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+                <span class="sr-only">Fehler:</span>
+                @error
+              </p>
+            }
+          }
+        </div>
+        <div class="edit-milestones-form">
+          <form action="@action" class="pure-form pure-form-aligned" method="POST" accept-charset="UTF-8">
+            <fieldset>
+              <input type="hidden" id="@fieldId" name="@fieldId" readonly="" value="@milestone.id">
+              <div class="pure-control-group">
+                <milestone for="@{fieldTitle}">@Messages("form.milestone.title")</milestone>
+                <input id="@{fieldTitle}" name="@{fieldTitle}" maxlength="40" placeholder="@Messages("form.milestone.title.placeholder")" required="" type="text" value="@{formData.get(fieldTitle)}">
+                <span class="pure-form-message" id="@{fieldTitle}.help" style="margin-left: 13em;">@Messages("form.milestone.title.help")</span>
+                @renderFormErrors(fieldTitle, formErrors)
+              </div>
+              <div class="pure-control-group">
+                <milestone for="@{fieldDescription}">@Messages("form.milestone.description")</milestone>
+                <input class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" maxlength="254" placeholder="@Messages("form.milestone.description.placeholder")" type="text" value="@{formData.get(fieldDescription)}">
+                <span class="pure-form-message" id="@{fieldDescription}.help" style="margin-left: 13em;">@Messages("form.milestone.description.help")</span>
+                @renderFormErrors(fieldDescription, formErrors)
+              </div>
+              <div class="pure-control-group">
+                <milestone for="@{fieldDueDate}">@Messages("form.milestone.due-date")</milestone>
+                <input id="@{fieldDueDate}" name="@{fieldDueDate}" type="date" value="@{formData.get(fieldDueDate)}">
+                <span class="pure-form-message" id="@{fieldDueDate}.help" style="margin-left: 13em;">@Messages("form.milestone.due-date.help")</span>
+                @renderFormErrors(fieldDueDate, formErrors)
+              </div>
+              @csrfToken(csrf)
+              <button type="submit" class="pure-button pure-button-success">@Messages("form.milestone.edit.button.submit")</button>
+            </fieldset>
+          </form>
+        </div>
+      </div>
+    </div>
+    } else { }
+  </div>
+  <div class="pure-g">
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+      </div>
+    </div>
+  </div>
+</div>
+}
+}
+
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestones.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestones.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestones.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestones.scala.html	2025-01-31 19:50:42.617163338 +0000
@@ -0,0 +1,127 @@
+@import java.time._
+@import de.smederee.hub.views.html.format._
+@import de.smederee.hub.views.html.showRepositoryMenu
+@import de.smederee.tickets.MilestoneForm._
+@import de.smederee.tickets._
+
+@(baseUri: Uri = Uri(path = Uri.Path.Root),
+  lang: LanguageCode = LanguageCode("en")
+)(action: Uri,
+  csrf: Option[CsrfToken] = None,
+  milestones: List[Milestone],
+  repositoryBaseUri: Uri,
+  title: Option[String] = None,
+  user: Option[Account],
+  vcsRepository: VcsRepository
+)(formData: Map[String, String] = Map.empty,
+  formErrors: FormErrors = FormErrors.empty
+)
+@main(baseUri, lang)()(csrf, title, user) {
+@defining(lang.toLocale) { implicit locale =>
+<div class="content">
+  <div class="pure-g">
+    <div class="pure-u-1">
+      <div class="l-box-left-right">
+        <h2><a href="@{baseUri.addSegment(s"~${vcsRepository.owner.name}")}">~@vcsRepository.owner.name</a>/@vcsRepository.name</h2>
+        @showRepositoryMenu(baseUri)(action.some, repositoryBaseUri, user, vcsRepository)
+        <div class="repo-summary-description">
+          @if(user.exists(_.uid === vcsRepository.owner.uid)) {
+            @Messages("repository.milestones.edit.title")
+          } else {
+            @Messages("repository.milestones.view.title")
+          }
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="pure-g">
+    @if(user.exists(_.uid === vcsRepository.owner.uid)) {
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+        <h4>@Messages("repository.milestones.add.title")</h4>
+        <div class="form-errors">
+          @formErrors.get(fieldGlobal).map { es =>
+            @for(error <- es) {
+              <p class="alert alert-error">
+                <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+                <span class="sr-only">Fehler:</span>
+                @error
+              </p>
+            }
+          }
+        </div>
+        <div class="edit-milestones-form">
+          <form action="@repositoryBaseUri.addSegment("milestones")" class="pure-form pure-form-stacked" method="POST" accept-charset="UTF-8">
+            <fieldset>
+              <div class="pure-control-group">
+                <label for="@{fieldTitle}">@Messages("form.milestone.title")</label>
+                <input class="pure-input-3-4" id="@{fieldTitle}" name="@{fieldTitle}" maxlength="40" placeholder="@Messages("form.milestone.title.placeholder")" required="" type="text" value="@{formData.get(fieldTitle)}">
+                <span class="pure-form-message" id="@{fieldTitle}.help">@Messages("form.milestone.title.help")</span>
+                @renderFormErrors(fieldTitle, formErrors)
+              </div>
+              <div class="pure-control-group">
+                <label for="@{fieldDescription}">@Messages("form.milestone.description")</label>
+                <textarea class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" placeholder="@Messages("form.milestone.description.placeholder")" rows="4" value="@{formData.get(fieldDescription)}"></textarea>
+                <span class="pure-form-message" id="@{fieldDescription}.help">@Messages("form.milestone.description.help")</span>
+                @renderFormErrors(fieldDescription, formErrors)
+              </div>
+              <div class="pure-control-group">
+                <label for="@{fieldDueDate}">@Messages("form.milestone.due-date")</label>
+                <input id="@{fieldDueDate}" name="@{fieldDueDate}" type="date" value="@{formData.get(fieldDueDate)}">
+                <span class="pure-form-message" id="@{fieldDueDate}.help">@Messages("form.milestone.due-date.help")</span>
+                @renderFormErrors(fieldDueDate, formErrors)
+              </div>
+              @csrfToken(csrf)
+              <button type="submit" class="pure-button pure-button-success">@Messages("form.milestone.create.button.submit")</button>
+            </fieldset>
+          </form>
+        </div>
+      </div>
+    </div>
+    } else { }
+  </div>
+  <div class="pure-g">
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+        <div class="milestone-list">
+          <h4>@Messages("repository.milestones.list.title", milestones.size)</h4>
+          @if(milestones.size === 0) {
+            <div class="alert alert-info">@Messages("repository.milestones.list.empty")</div>
+          } else {
+            @defining(32) { lineHeight =>
+              @for(milestone <- milestones) {
+                <div class="pure-g milestone">
+                  <div class="pure-u-1-24 milestone-icon">
+                    @icon(baseUri)("flag", lineHeight.some)
+                  </div>
+                  <div class="pure-u-5-24 milestone-title" style="height: @{lineHeight}px; line-height: @{lineHeight}px;">@milestone.title @for(dueDate <- milestone.dueDate) { @formatDate(dueDate) }</div>
+                  <div class="pure-u-10-24 milestone-description" style="height: @{lineHeight}px; line-height: @{lineHeight}px; overflow: overlay;">@milestone.description</div>
+                  <div class="pure-u-2-24" style="height: @{lineHeight}px; line-height: @{lineHeight}px; margin: auto;">
+                    @if(user.exists(_.uid === vcsRepository.owner.uid)) {
+                    <a class="pure-button" href="@repositoryBaseUri.addSegment("milestone").addSegment(milestone.title.toString).addSegment("edit")" title="@Messages("repository.milestone.edit.title", milestone.title)">@Messages("repository.milestone.edit.link")</a>
+                    } else { }
+                  </div>
+                  <div class="pure-u-6-24 milestone-form" style="height: @{lineHeight}px; line-height: @{lineHeight}px; padding-left: 1em;">
+                    @if(user.exists(_.uid === vcsRepository.owner.uid)) {
+                    <form action="@repositoryBaseUri.addSegment("milestone").addSegment(milestone.title.toString).addSegment("delete")" class="pure-form" method="POST" accept-charset="UTF-8">
+                      <fieldset>
+                        <input type="hidden" id="@fieldId-@milestone.title" name="@fieldId" readonly="" value="@milestone.id">
+                        <input type="hidden" id="@fieldTitle-@milestone.title" name="@fieldTitle" readonly="" value="@milestone.title">
+                        <milestone for="i-am-sure-@milestone.title"><input id="i-am-sure-@milestone.title" name="i-am-sure" required="" type="checkbox" value="yes"/> @Messages("form.milestone.delete.i-am-sure")</milestone>
+                        @csrfToken(csrf)
+                        <button type="submit" class="pure-button pure-button-warning">@Messages("form.milestone.delete.button.submit")</button>
+                      </fieldset>
+                    </form>
+                    } else { }
+                  </div>
+                </div>
+              }
+            }
+          }
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+}
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/Generators.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/Generators.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/Generators.scala	2025-01-31 19:50:42.613163332 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/Generators.scala	2025-01-31 19:50:42.617163338 +0000
@@ -17,10 +17,13 @@
 
 package de.smederee.tickets
 
+import java.time._
+
 import org.scalacheck.{ Arbitrary, Gen }
-import java.time.LocalDate
 
 object Generators {
+  val MinimumYear: Int = -4713  // Lowest year supported by PostgreSQL
+  val MaximumYear: Int = 294276 // Highest year supported by PostgreSQL
 
   /** Prepend a zero to a single character hexadecimal code.
     *
@@ -36,6 +39,15 @@
     else
       hexCode
 
+  val genLocalDate: Gen[LocalDate] =
+    for {
+      year  <- Gen.choose(MinimumYear, MaximumYear)
+      month <- Gen.choose(1, 12)
+      day   <- Gen.choose(1, Month.of(month).length(Year.of(year).isLeap))
+    } yield LocalDate.of(year, month, day)
+
+  given Arbitrary[LocalDate] = Arbitrary(genLocalDate)
+
   val genLabelName: Gen[LabelName] =
     Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(LabelName.MaxLength).mkString).map(LabelName.apply)
 
@@ -58,14 +70,6 @@
 
   val genLabels: Gen[List[Label]] = Gen.nonEmptyListOf(genLabel).map(_.distinct)
 
-  val genLocalDate: Gen[LocalDate] =
-    for {
-      year  <- Gen.choose(LocalDate.MIN.getYear(), LocalDate.MAX.getYear())
-      month <- Gen.choose(1, 12)
-      maxDays = LocalDate.of(year, month, 1).lengthOfMonth()
-      day <- Gen.choose(1, maxDays)
-    } yield LocalDate.of(year, month, day)
-
   val genMilestoneTitle: Gen[MilestoneTitle] =
     Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(MilestoneTitle.MaxLength).mkString).map(MilestoneTitle.apply)
 
@@ -74,9 +78,12 @@
 
   val genMilestone: Gen[Milestone] =
     for {
+      id    <- Gen.option(Gen.choose(0L, Long.MaxValue).map(MilestoneId.apply))
       title <- genMilestoneTitle
       due   <- Gen.option(genLocalDate)
       descr <- Gen.option(genMilestoneDescription)
-    } yield Milestone(title, due, descr)
+    } yield Milestone(id, title, descr, due)
+
+  val genMilestones: Gen[List[Milestone]] = Gen.nonEmptyListOf(genMilestone).map(_.distinct)
 
 }