diff --git a/README.md b/README.md
index 4602de5..597584e 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,7 @@
![Activities Chart](./mpb-activities.png)
![Activity Chart](./mpb-activity.png)
+![Profile Chart](./mpb-profile.png)
这是一款面向 **入门跑者** 和 **[MAF训练法][maf]跑者** 的,聚焦于跑步训练中 **[速心比](#速心比meters-per-beat)** 指标数据可视化的浏览器扩展程序(俗称:插件)。
diff --git a/mpb-profile.png b/mpb-profile.png
new file mode 100644
index 0000000..e190f93
Binary files /dev/null and b/mpb-profile.png differ
diff --git a/mpb-workout.png b/mpb-workout.png
index 42cc539..589f75d 100644
Binary files a/mpb-workout.png and b/mpb-workout.png differ
diff --git a/src/Main.scala b/src/Main.scala
index a1c8f39..f9a2e01 100644
--- a/src/Main.scala
+++ b/src/Main.scala
@@ -3,7 +3,7 @@ import org.scalajs.dom.*
object Main:
def main(args: Array[String]): Unit =
- val route = OnPageActivities() orElse OnPageActivity() orElse OnPageWorkout()
+ val route = OnPageActivities() orElse OnPageActivity() orElse OnPageWorkout() orElse OnPageProfile()
MutationObserver: (rs, _) =>
route.applyOrElse((new URL(window.location.href), rs.toSeq), _ => ())
.observe(
diff --git a/src/OnPageActivities.scala b/src/OnPageActivities.scala
index d0c904a..befb308 100644
--- a/src/OnPageActivities.scala
+++ b/src/OnPageActivities.scala
@@ -12,11 +12,13 @@ import plot.*
object OnPageActivities:
- def apply()(using a: Anchor[HTMLElement], s: Scatter[Activities]): Route =
+ def apply()(using a: Anchor[HTMLElement], s: Scatter[SearchResult]): Route =
case (Activities(tp), HasListItem()) =>
val root = a.init(document.querySelector("div.advanced-filtering").before(_), "mpb")
- if tp != "running" then root.remove()
- else for gas <- Service.activities(SearchFilter(tp.get)) do Plot(root, Array(gas), "速心比变化趋势")(using _.scatter)
+ if tp == "running" || tp == undefined then
+ for gas <- Service.activities(SearchFilter("running")) if gas.nonEmpty do
+ Plot(root, Array(gas), "速心比变化趋势")(using _.scatter)
+ else root.remove()
end apply
private val Activities = Extract[URL, UndefOr[String]]:
diff --git a/src/OnPageActivity.scala b/src/OnPageActivity.scala
index c08be68..2cfb2a5 100644
--- a/src/OnPageActivity.scala
+++ b/src/OnPageActivity.scala
@@ -21,7 +21,7 @@ object OnPageActivity:
t <- a.activityTypeDTO
k <- t.typeKey if k == "running"
do
- for laps <- Service.laps(id.toDouble) do
+ for laps <- Service.laps(id.toDouble) if laps.nonEmpty do
Plot(aa.init(e.before(_), "scatter"), Arr(laps), "速心比变化趋势")(using _.scatter)
end apply
diff --git a/src/OnPageProfile.scala b/src/OnPageProfile.scala
new file mode 100644
index 0000000..9ceec21
--- /dev/null
+++ b/src/OnPageProfile.scala
@@ -0,0 +1,44 @@
+import scala.concurrent.ExecutionContext.Implicits.global
+import scala.scalajs.js
+
+import org.scalajs.dom.*
+
+import common.Anchor
+import common.Extract
+import common.Functional.*
+import common.GetElement.query
+import common.GetMutationRecord.addedNodes
+import common.GetURL.path
+import common.Regex.*
+import garmin.Pagination
+import garmin.SearchResult
+import garmin.Service
+import plot.Plot
+import plot.Scatter
+
+object OnPageProfile:
+ def apply()(using a: Anchor[HTMLElement], s: Scatter[SearchResult]): Route =
+ case (Profile(Seq(_, id: String)), PageContent(e)) =>
+ val p: Pagination = new:
+ start = 1
+ limit = 30
+ for
+ ap <- Service.activitiesByProfile(id, p)
+ al <- ap.activityList
+ do
+ val sr = al.filter(_.activityType.get.typeKey == "running")
+ if sr.nonEmpty then
+ val layout = Plot.Layout("近期跑步速心比变化趋势")
+ val config = Plot.Config().setDisplayModeBar(false)
+ Plot(a.init(e.before(_), "scatter"), js.Array(sr), layout, config)(using _.scatter)
+ end apply
+
+ private val Profile = Extract[URL, Seq[js.UndefOr[String]]]:
+ path |> "/modern/profile/(.+)".capture
+
+ private val PageContent = Extract[Seq[MutationRecord], Element]:
+ inline def E = Extract[Element, Element]:
+ query("div[class^=\"PageContent\"]").??
+
+ _.flatMap(addedNodes[Element]).collectFirst({ case E(e) => e })
+end OnPageProfile
diff --git a/src/OnPageWorkout.scala b/src/OnPageWorkout.scala
index 52535cf..2622ae2 100644
--- a/src/OnPageWorkout.scala
+++ b/src/OnPageWorkout.scala
@@ -14,12 +14,14 @@ import garmin.*
import plot.*
object OnPageWorkout:
- def apply()(using a: Anchor[HTMLElement], s: Scatter[ActivityLaps], b: Box[ActivityLaps]): Route =
+ def apply()(using a: Anchor[HTMLElement], s: Scatter[ActivityByWorkout], b: Box[ActivityByWorkout]): Route =
case (Workout(Seq(_, id: String), "running"), PageHeader(e)) =>
- for list <- Service.activityLapsList(id.toDouble, 7) do
- val r = list.reverse
- Plot(a.init(e.appendChild, "box"), r, "近七日速心比分布趋势")(using _.box)
- Plot(a.init(e.appendChild, "scatter"), r, "近七日速心比趋势对比")(using _.scatter)
+ for list <- Service.activityLapsList(id.toDouble, 30) if list.nonEmpty do
+ val limit = Math.min(list.length, 14)
+ val latest = Math.min(limit, 5)
+ val layout = Plot.Layout(s"最近${limit}次速心比分布趋势").setShowlegend(false)
+ Plot(a.init(e.appendChild, "box"), list.take(limit).reverse, layout)(using _.box)
+ Plot(a.init(e.appendChild, "scatter"), list.take(latest).reverse, s"近${latest}次速心比趋势对比")(using _.scatter)
private val Workout = Extract[URL, (Seq[UndefOr[String]], UndefOr[String])]:
(path |> "/modern/workout/(\\d+)".capture) ~> param("workoutType")
diff --git a/src/common/GetMutationRecord.scala b/src/common/GetMutationRecord.scala
index 679f538..27ca6d6 100644
--- a/src/common/GetMutationRecord.scala
+++ b/src/common/GetMutationRecord.scala
@@ -4,5 +4,5 @@ import org.scalajs.dom.*
object GetMutationRecord:
import scala.reflect.Typeable
- inline def addedNodes[A >: Element: Typeable]: MutationRecord => Seq[A] =
- _.addedNodes.toSeq.collect({ case a: A => a })
+ inline def addedNodes[A >: Element: Typeable]: MutationRecord => Seq[A] = r =>
+ for case a: A <- r.addedNodes.toSeq yield a
diff --git a/src/garmin/ActivitiesByProfile.scala b/src/garmin/ActivitiesByProfile.scala
new file mode 100644
index 0000000..fa3027b
--- /dev/null
+++ b/src/garmin/ActivitiesByProfile.scala
@@ -0,0 +1,6 @@
+package garmin
+
+import scala.scalajs.js
+
+trait ActivitiesByProfile extends js.Object:
+ var activityList: js.UndefOr[SearchResult] = js.undefined
diff --git a/src/garmin/Activity.scala b/src/garmin/Activity.scala
index b10601e..fd2d8e1 100644
--- a/src/garmin/Activity.scala
+++ b/src/garmin/Activity.scala
@@ -5,5 +5,6 @@ import scala.scalajs.js
trait Activity extends Performance:
var activityId: js.UndefOr[Double] = js.undefined
var activityName: js.UndefOr[String] = js.undefined
- var summaryDTO: js.UndefOr[Performance] = js.undefined
+ var summaryDTO: js.UndefOr[Performance] = js.undefined
var activityTypeDTO: js.UndefOr[ActivityType] = js.undefined
+ var activityType: js.UndefOr[ActivityType] = js.undefined
diff --git a/src/garmin/Pagination.scala b/src/garmin/Pagination.scala
new file mode 100644
index 0000000..7d06047
--- /dev/null
+++ b/src/garmin/Pagination.scala
@@ -0,0 +1,8 @@
+package garmin
+
+import scala.scalajs.js
+
+trait Pagination extends js.Object:
+ var excludeChildren: js.UndefOr[Boolean] = js.undefined
+ var start: js.UndefOr[Int | String] = js.undefined
+ var limit: js.UndefOr[Int | String] = js.undefined
diff --git a/src/garmin/SearchFilter.scala b/src/garmin/SearchFilter.scala
index 9965a22..371304e 100644
--- a/src/garmin/SearchFilter.scala
+++ b/src/garmin/SearchFilter.scala
@@ -3,11 +3,8 @@ package garmin
import scala.scalajs.js
import scala.scalajs.js.annotation.JSName
-trait SearchFilter extends js.Object:
+trait SearchFilter extends Pagination:
var activityType: js.UndefOr[String] = js.undefined
- var start: js.UndefOr[Int | String] = js.undefined
- var limit: js.UndefOr[Int | String] = js.undefined
- var excludeChildren: js.UndefOr[Boolean] = js.undefined
var startDate: js.UndefOr[String] = js.undefined
@JSName("_") var now: js.UndefOr[Double] = js.undefined
diff --git a/src/garmin/Service.scala b/src/garmin/Service.scala
index 42e589c..3915be0 100644
--- a/src/garmin/Service.scala
+++ b/src/garmin/Service.scala
@@ -11,12 +11,11 @@ import common.*
import common.Functional.*
object Service:
- def activityLapsList(workoutId: Double, latestDays: Double)(using
+ def activityLapsList(workoutId: Double, limit: Int)(using
DateFormat[Double]
- ): Future[js.Array[ActivityLaps]] =
- val cur = js.Date.now()
+ ): Future[js.Array[ActivityByWorkout]] =
val filter = SearchFilter("running")
- filter.startDate = cur.dayBefore(7).ymd("fr-CA")
+ filter.limit = limit
activities(filter)
.flatMap: sa =>
@@ -25,6 +24,10 @@ object Service:
.map(_.toJSArray)
end activityLapsList
+ def activitiesByProfile(id: String, p: Pagination) =
+ val url = s"${base}/activitylist-service/activities/$id${params(p)}"
+ get[ActivitiesByProfile](url, s"https://connect.garmin.cn/modern/profile/$id")
+
def activity(id: Double) =
val url = s"${base}/activity-service/activity/$id?_=${js.Date.now()}"
get[Activity](url, s"https://connect.garmin.cn/modern/activity/$id")
@@ -32,7 +35,7 @@ object Service:
def activities(filter: SearchFilter) =
val url = s"${base}/activitylist-service/activities/search/activities${params(filter)}"
// TODO improve referer
- get[Activities](url, "https://connect.garmin.cn/modern/activities")
+ get[SearchResult](url, "https://connect.garmin.cn/modern/activities")
def laps(activityId: Double) =
inline def equal[A](a: A): js.UndefOr[A] => Boolean = a == _
diff --git a/src/garmin/package.scala b/src/garmin/package.scala
index d270731..bb76e60 100644
--- a/src/garmin/package.scala
+++ b/src/garmin/package.scala
@@ -4,7 +4,7 @@ import scala.scalajs.js
inline val base = "https://connect.garmin.cn"
-type Activities = js.Array[ActivityItem]
-type Laps = js.Array[Lap]
-type ActivityItem = Activity & Workout & Performance
-type ActivityLaps = (Activity & Workout, Laps)
+type SearchResult = js.Array[ActivityBySearch]
+type Laps = js.Array[Lap]
+type ActivityBySearch = Activity & Workout & Performance
+type ActivityByWorkout = (Activity & Workout, Laps)
diff --git a/src/plot/Box.scala b/src/plot/Box.scala
index 0ca37dc..d913c2d 100644
--- a/src/plot/Box.scala
+++ b/src/plot/Box.scala
@@ -18,10 +18,10 @@ object Box:
.PartialPlotDataAutobinx()
.setY(laps.map(_.mpb))
.setHoverinfo(cs.y)
- .setWidth(0.1)
+ .setWidth(0.3)
.setType(cs.box)
- given activityLaps(using Box[Laps], DateFormat[String]): Box[ActivityLaps] = ga =>
+ given activityLaps(using Box[Laps], DateFormat[String]): Box[ActivityByWorkout] = ga =>
ga match
case (a, laps) =>
laps.box
diff --git a/src/plot/Plot.scala b/src/plot/Plot.scala
index 351bb1d..6fa12ac 100644
--- a/src/plot/Plot.scala
+++ b/src/plot/Plot.scala
@@ -1,6 +1,6 @@
package plot
-import scala.scalajs.js.*
+import scala.scalajs.js
import org.scalajs.dom.*
@@ -9,8 +9,15 @@ import typings.plotlyJs.mod.*
import typings.plotlyJsDistMin.mod
object Plot:
- def apply[A](root: HTMLElement, data: Array[A], title: String)(using Conversion[A, Data]) =
- mod.newPlot(root, data.map(_.convert), Layout(title), Config())
+ def apply[A](root: HTMLElement, data: js.Array[A], title: String)(using
+ Conversion[A, Data]
+ ): js.Promise[PlotlyHTMLElement] =
+ apply(root, data, Layout(title), Config())
+
+ def apply[A](root: HTMLElement, data: js.Array[A], layout: PartialLayout, config: PartialConfig = Config())(using
+ Conversion[A, Data]
+ ): js.Promise[PlotlyHTMLElement] =
+ mod.newPlot(root, data.map(_.convert), layout, config)
object Layout:
def apply(title: String) = PartialLayout()
diff --git a/src/plot/Scatter.scala b/src/plot/Scatter.scala
index 94022a0..1e3b22d 100644
--- a/src/plot/Scatter.scala
+++ b/src/plot/Scatter.scala
@@ -26,7 +26,7 @@ object Scatter:
.setHovertext(laps.map(_.text))
.setHovertemplate("%{y:.3f}
%{hovertext}")
- given activityLaps(using Scatter[Laps], DateFormat[String]): Scatter[ActivityLaps] = ga =>
+ given activityLaps(using Scatter[Laps], DateFormat[String]): Scatter[ActivityByWorkout] = ga =>
ga match
case (a, laps) =>
laps.scatter
@@ -36,7 +36,7 @@ object Scatter:
given activities(using
MetersPerBeat[Performance]
- ): Scatter[js.Array[ActivityItem]] = gas =>
+ ): Scatter[SearchResult] = gas =>
Data
.PartialPlotDataAutobinx()
.setY(gas.map(_.mpb))