From 2fb1dbe1d9751769a7b18d4c4c7453eec0e4d506 Mon Sep 17 00:00:00 2001 From: Pavel Date: Wed, 3 Dec 2025 19:07:42 +0000 Subject: [PATCH 1/9] feat: implement drop row functionality; --- frontend/Controller.scala | 3 +++ frontend/src/braid/View.scala | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/Controller.scala b/frontend/Controller.scala index 99e785d..7545f50 100644 --- a/frontend/Controller.scala +++ b/frontend/Controller.scala @@ -16,4 +16,7 @@ class Controller(model: Var[Map[Int, Habit]]) { case None => habits } ) + def dropSingleRow(index: Int): Unit = { + model.update(habits => habits.removed(index)) + } } diff --git a/frontend/src/braid/View.scala b/frontend/src/braid/View.scala index 0cc97d6..f71ac3d 100644 --- a/frontend/src/braid/View.scala +++ b/frontend/src/braid/View.scala @@ -105,9 +105,10 @@ class View(controller: Controller) { className := "px-4 py-4 text-center", button( "Delete", - onClick.flatMap(_ => FetchStream.post("/habit/" + habit.id)) --> { - responseText => println(responseText) - }, + onClick --> controller.dropSingleRow(habit.id), +// onClick.flatMap(_ => FetchStream.post("/habit/" + habit.id)) --> { +// responseText => println(responseText) +// }, className := "text-red-500 hover:text-red-700 transition" ) ) From b27300f681d2b4c6537c7efa74d883ef13927d16 Mon Sep 17 00:00:00 2001 From: Pavel Date: Wed, 3 Dec 2025 19:59:46 +0000 Subject: [PATCH 2/9] feat: compose drop with sending event to the server side; --- frontend/src/braid/View.scala | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/braid/View.scala b/frontend/src/braid/View.scala index f71ac3d..29b6ec5 100644 --- a/frontend/src/braid/View.scala +++ b/frontend/src/braid/View.scala @@ -105,10 +105,12 @@ class View(controller: Controller) { className := "px-4 py-4 text-center", button( "Delete", - onClick --> controller.dropSingleRow(habit.id), -// onClick.flatMap(_ => FetchStream.post("/habit/" + habit.id)) --> { -// responseText => println(responseText) -// }, + onClick.flatMap(_ => FetchStream.post("/habit/" + habit.id)) --> { + responseText => println(responseText) + }, + inContext(_.events(onClick).throttle(3000) --> { + controller.dropSingleRow(habit.id) + }), className := "text-red-500 hover:text-red-700 transition" ) ) From 94e74dea821f0dcec6ca02f195b0a42ffede350e Mon Sep 17 00:00:00 2001 From: Pavel Date: Wed, 3 Dec 2025 20:07:00 +0000 Subject: [PATCH 3/9] feat: flatMap two events --- frontend/src/braid/View.scala | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/frontend/src/braid/View.scala b/frontend/src/braid/View.scala index 29b6ec5..e550f3d 100644 --- a/frontend/src/braid/View.scala +++ b/frontend/src/braid/View.scala @@ -106,11 +106,10 @@ class View(controller: Controller) { button( "Delete", onClick.flatMap(_ => FetchStream.post("/habit/" + habit.id)) --> { - responseText => println(responseText) + responseText => + //println(responseText) || assume its a success response + controller.dropSingleRow(habit.id) }, - inContext(_.events(onClick).throttle(3000) --> { - controller.dropSingleRow(habit.id) - }), className := "text-red-500 hover:text-red-700 transition" ) ) From 3d4989848b6561774818a1c718f2103006af8d3a Mon Sep 17 00:00:00 2001 From: Pavel Date: Wed, 3 Dec 2025 20:14:38 +0000 Subject: [PATCH 4/9] feat: use designated route to update model --- backend/src/braid/Main.scala | 12 ++++++++++-- frontend/src/braid/View.scala | 9 +++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/backend/src/braid/Main.scala b/backend/src/braid/Main.scala index a75cdfd..9e9b198 100644 --- a/backend/src/braid/Main.scala +++ b/backend/src/braid/Main.scala @@ -20,12 +20,19 @@ object Main header = "An amazing web application built with Krop" ) { - val habitRoutes = + // TODO: need idea how to store model(s) on the server side: json? + val habitRoute = Route( Request.post(Path / "habit" / Param.int), Response.ok(Entity.text) ).handle(habitId => s"Response==>HabitId: $habitId") + val habitDeleteRoute = + Route( + Request.post(Path / "habit" / "delete" / Param.int), + Response.ok(Entity.text) + ).handle(habitId => s"Response==>HabitId: $habitId") + val home = Routes.home.handle(() => html.base(name, html.home(name, BuildInfo.kropVersion)).toString @@ -45,7 +52,8 @@ object Main val application = home - .orElse(habitRoutes) + .orElse(habitRoute) + .orElse(habitDeleteRoute) .orElse(javascript) .orElse(assets) .orElse(Application.notFound) diff --git a/frontend/src/braid/View.scala b/frontend/src/braid/View.scala index e550f3d..9ef2895 100644 --- a/frontend/src/braid/View.scala +++ b/frontend/src/braid/View.scala @@ -105,10 +105,11 @@ class View(controller: Controller) { className := "px-4 py-4 text-center", button( "Delete", - onClick.flatMap(_ => FetchStream.post("/habit/" + habit.id)) --> { - responseText => - //println(responseText) || assume its a success response - controller.dropSingleRow(habit.id) + onClick.flatMap(_ => + FetchStream.post("/habit/delete/" + habit.id) + ) --> { responseText => + // println(responseText) || assume its a success response + controller.dropSingleRow(habit.id) }, className := "text-red-500 hover:text-red-700 transition" ) From 3df9cadd51318e4502b3308783897179297caa4b Mon Sep 17 00:00:00 2001 From: Pavel Date: Wed, 3 Dec 2025 20:39:05 +0000 Subject: [PATCH 5/9] feat: add sorting by streak --- frontend/Controller.scala | 4 ++++ frontend/src/braid/View.scala | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/Controller.scala b/frontend/Controller.scala index 7545f50..6c97189 100644 --- a/frontend/Controller.scala +++ b/frontend/Controller.scala @@ -19,4 +19,8 @@ class Controller(model: Var[Map[Int, Habit]]) { def dropSingleRow(index: Int): Unit = { model.update(habits => habits.removed(index)) } + def sortByStreak(): Unit = { + // TODO: implement reverse sorting + model.update(habits => habits.toSeq.sortWith(_._1 > _._1).toMap) + } } diff --git a/frontend/src/braid/View.scala b/frontend/src/braid/View.scala index 9ef2895..8113e70 100644 --- a/frontend/src/braid/View.scala +++ b/frontend/src/braid/View.scala @@ -51,7 +51,12 @@ class View(controller: Controller) { ), th( className := "px-4 py-4 text-center text-sm font-semibold text-gray-700", - "Streak" + "Streak", + onClick --> { + // TODO: sort by streak + println("Sorting ...") + controller.sortByStreak() + } ), last7DaysTableHeadings, th(className := "px-4 py-4") From 274bb376fe59ca00d976d139cee059cb0131cc35 Mon Sep 17 00:00:00 2001 From: Pavel Date: Wed, 3 Dec 2025 20:56:47 +0000 Subject: [PATCH 6/9] feat: add sorting by streak --- frontend/Controller.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/Controller.scala b/frontend/Controller.scala index 6c97189..5bfcc64 100644 --- a/frontend/Controller.scala +++ b/frontend/Controller.scala @@ -20,7 +20,7 @@ class Controller(model: Var[Map[Int, Habit]]) { model.update(habits => habits.removed(index)) } def sortByStreak(): Unit = { - // TODO: implement reverse sorting + // TODO: implement reverse sorting | Use:: inContext model.update(habits => habits.toSeq.sortWith(_._1 > _._1).toMap) } } From 83134dd4d8b00ed472a46807d69cce41305473c5 Mon Sep 17 00:00:00 2001 From: Pavel Date: Sat, 13 Dec 2025 18:21:39 +0000 Subject: [PATCH 7/9] feat: implement local storage for habit model --- build.sbt | 9 +++- date/js/src/braid/date/Date.scala | 1 + frontend/Controller.scala | 6 +-- frontend/src/braid/Braid.scala | 9 +--- frontend/src/braid/View.scala | 77 ++++++++++++++++++++++++++++++- 5 files changed, 88 insertions(+), 14 deletions(-) diff --git a/build.sbt b/build.sbt index d21a62b..7c2363c 100644 --- a/build.sbt +++ b/build.sbt @@ -12,6 +12,7 @@ val kropVersion = "0.9.4" val laminarVersion = "17.2.1" val logbackVersion = "1.5.18" val munitVersion = "1.1.1" +val Borer = "1.14.0" // Run this command (build) to do everything involved in building the project commands += Command.command("build") { state => @@ -93,7 +94,13 @@ lazy val frontend = project .settings( name := """braid-frontend""", commonSettings, - libraryDependencies += "com.raquo" %%% "laminar" % laminarVersion, + libraryDependencies ++= Seq( + "com.raquo" %%% "laminar" % laminarVersion, + "io.bullet" %%% "borer-core" % Borer, + "io.bullet" %%% "borer-derivation" % Borer + ), + // JSON codec + scalaJSUseMainModuleInitializer := true ) .enablePlugins(ScalaJSPlugin, KropLayout) diff --git a/date/js/src/braid/date/Date.scala b/date/js/src/braid/date/Date.scala index 570fcf1..8afeb15 100644 --- a/date/js/src/braid/date/Date.scala +++ b/date/js/src/braid/date/Date.scala @@ -75,4 +75,5 @@ object Date { def today(): Date = { fromJsDate(new js.Date(js.Date.now())) } + } diff --git a/frontend/Controller.scala b/frontend/Controller.scala index 5bfcc64..9681034 100644 --- a/frontend/Controller.scala +++ b/frontend/Controller.scala @@ -4,8 +4,8 @@ import braid.Habit import braid.date.Date import com.raquo.laminar.api.L.{_, given} -class Controller(model: Var[Map[Int, Habit]]) { - def toggleCompletionDate(id: Int, date: Date): Unit = +class Controller(model: Var[Map[String, Habit]]) { + def toggleCompletionDate(id: String, date: Date): Unit = model.update(habits => habits.get(id) match { case Some(habit) => @@ -16,7 +16,7 @@ class Controller(model: Var[Map[Int, Habit]]) { case None => habits } ) - def dropSingleRow(index: Int): Unit = { + def dropSingleRow(index: String): Unit = { model.update(habits => habits.removed(index)) } def sortByStreak(): Unit = { diff --git a/frontend/src/braid/Braid.scala b/frontend/src/braid/Braid.scala index f911e50..0fc4b5f 100644 --- a/frontend/src/braid/Braid.scala +++ b/frontend/src/braid/Braid.scala @@ -9,14 +9,7 @@ import scala.scalajs.js object Braid { def run(mount: dom.Element): Unit = { - val model = - Var( - Map( - 1 -> Habit(1, "Work on Braid", 0, Seq()), - 2 -> Habit(2, "Exercise", 5, Seq()), - 3 -> Habit(3, "Read", 3, Seq(Date.today())) - ) - ) + val model = Habit.habitsVar val controller = Controller(model) val view = View(controller) diff --git a/frontend/src/braid/View.scala b/frontend/src/braid/View.scala index 8113e70..8d7fde7 100644 --- a/frontend/src/braid/View.scala +++ b/frontend/src/braid/View.scala @@ -1,14 +1,50 @@ package braid import braid.date.Date +import braid.date.Day import braid.model.Habit +import com.raquo.airstream.web.WebStorageVar import com.raquo.laminar.api.L.{_, given} import com.raquo.laminar.api.features.unitArrows import com.raquo.laminar.nodes.ReactiveHtmlElement +import io.bullet.borer.Codec +import io.bullet.borer.Json +import io.bullet.borer.derivation.MapBasedCodecs._ +import java.nio.charset.StandardCharsets import scala.scalajs.js +import scala.util.Try -final case class Habit(id: Int, name: String, streak: Int, dates: Seq[Date]) +final case class Habit(id: String, name: String, streak: Int, dates: Seq[Date]) + +object Habit { + + val defaultValues = Map( + "1" -> Habit("1", "Work on Braid", 0, Seq()), + "2" -> Habit("2", "Exercise", 5, Seq()), + "3" -> Habit("3", "Read", 3, Seq(Date.today())) + ) + + given habitCodec: Codec[Habit] = deriveCodec + given dateCodec: Codec[Date] = deriveCodec + given dayCodec: Codec[braid.date.Day] = deriveCodec + given monthCodec: Codec[braid.date.Month] = deriveCodec + + val habitsVar = WebStorageVar + .localStorage( + key = "habits", + syncOwner = None + ) + .withCodec[Map[String, Habit]]( + encode = hs => Json.encode(hs).toUtf8String, + decode = jsonStr => + Json + .decode(jsonStr.getBytes(StandardCharsets.UTF_8)) + .to[Map[String, Habit]] + .valueTry, + default = Try(defaultValues) + ) +} class View(controller: Controller) { def last7Days: Seq[Date] = { @@ -35,9 +71,46 @@ class View(controller: Controller) { ) } - def view(habits: Signal[Map[Int, Habit]]): Div = + private def availabilityChecker( + label: String, + check: () => Boolean + ): HtmlElement = { + val storageAvailableVar = Var(check()) + p( + span( + text <-- storageAvailableVar.signal.map { + case true => s"✅ $label is available" + case false => s"🛑 User denied access to $label" + }, + marginRight.px(10) + ), + button( + typ("button"), + inContext { thisNode => + text <-- + thisNode + .events(onClick) + .delayWithStatus(300) + .map { + case Pending(_) => "Checking..." + case _ => "Check again" + } + .startWith("Check again") + }, + onClick.mapTo( + WebStorageVar.isLocalStorageAvailable() + ) --> storageAvailableVar + ) + ) + } + + def view(habits: Signal[Map[String, Habit]]): Div = div( className := "bg-white rounded-lg shadow-lg overflow-hidden", + availabilityChecker( + "LocalStorage", + WebStorageVar.isLocalStorageAvailable + ), div( className := "overflow-x-auto", table( From 41d9da3f909a721f31dc36800e7bbbe9fc06a546 Mon Sep 17 00:00:00 2001 From: Pavel Date: Sat, 13 Dec 2025 18:26:56 +0000 Subject: [PATCH 8/9] refactor: drop availabilityChecker function / lets not do code copy/paste --- frontend/src/braid/View.scala | 37 ----------------------------------- 1 file changed, 37 deletions(-) diff --git a/frontend/src/braid/View.scala b/frontend/src/braid/View.scala index 8d7fde7..c712cf9 100644 --- a/frontend/src/braid/View.scala +++ b/frontend/src/braid/View.scala @@ -71,46 +71,9 @@ class View(controller: Controller) { ) } - private def availabilityChecker( - label: String, - check: () => Boolean - ): HtmlElement = { - val storageAvailableVar = Var(check()) - p( - span( - text <-- storageAvailableVar.signal.map { - case true => s"✅ $label is available" - case false => s"🛑 User denied access to $label" - }, - marginRight.px(10) - ), - button( - typ("button"), - inContext { thisNode => - text <-- - thisNode - .events(onClick) - .delayWithStatus(300) - .map { - case Pending(_) => "Checking..." - case _ => "Check again" - } - .startWith("Check again") - }, - onClick.mapTo( - WebStorageVar.isLocalStorageAvailable() - ) --> storageAvailableVar - ) - ) - } - def view(habits: Signal[Map[String, Habit]]): Div = div( className := "bg-white rounded-lg shadow-lg overflow-hidden", - availabilityChecker( - "LocalStorage", - WebStorageVar.isLocalStorageAvailable - ), div( className := "overflow-x-auto", table( From 39009a7cfefc855562e88f577c253e7de33e994e Mon Sep 17 00:00:00 2001 From: Pavel Date: Sat, 13 Dec 2025 20:27:43 +0000 Subject: [PATCH 9/9] comment: we can use session storage instead of local storage --- frontend/src/braid/View.scala | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/braid/View.scala b/frontend/src/braid/View.scala index c712cf9..4b5cd2a 100644 --- a/frontend/src/braid/View.scala +++ b/frontend/src/braid/View.scala @@ -30,6 +30,8 @@ object Habit { given dayCodec: Codec[braid.date.Day] = deriveCodec given monthCodec: Codec[braid.date.Month] = deriveCodec + // We can use session storage instead:: + // https://github.com/raquo/Airstream/?tab=readme-ov-file#sessionstorage val habitsVar = WebStorageVar .localStorage( key = "habits",