From 97b9ed40ded49e102036ff56349cb7f44e163612 Mon Sep 17 00:00:00 2001 From: Todd Burnside Date: Wed, 12 Feb 2025 16:29:28 -0800 Subject: [PATCH] Display Time Accounting by Science Band --- .../scala/explore/model/reusability.scala | 2 +- .../queries/common/ProgramTimesSubquery.scala | 1 + .../explore/programs/ProgramDetailsTile.scala | 4 +- .../programs/TimeAccountingTable.scala | 199 ++++++++++++------ .../explore/programs/TimeAwardTable.scala | 37 ++-- .../scala/explore/model/ProgramTimes.scala | 5 +- 6 files changed, 158 insertions(+), 90 deletions(-) diff --git a/common/src/main/scala/explore/model/reusability.scala b/common/src/main/scala/explore/model/reusability.scala index e93f45c634..324a75dab7 100644 --- a/common/src/main/scala/explore/model/reusability.scala +++ b/common/src/main/scala/explore/model/reusability.scala @@ -8,7 +8,6 @@ import cats.data.NonEmptyChain import cats.syntax.all.* import clue.PersistentClientStatus import explore.data.KeyedIndexedList -import explore.model.IsActive import explore.model.enums.AgsState import explore.model.enums.SelectedPanel import explore.model.itc.ItcExposureTime @@ -72,6 +71,7 @@ object reusability: given Reusability[ProgramInfo] = Reusability.byEq given Reusability[ProgramDetails] = Reusability.byEq given Reusability[Execution] = Reusability.byEq + given Reusability[BandedProgramTime] = Reusability.byEq /** */ diff --git a/explore/src/clue/scala/queries/common/ProgramTimesSubquery.scala b/explore/src/clue/scala/queries/common/ProgramTimesSubquery.scala index 69d8b367e8..5b45fa6dfe 100644 --- a/explore/src/clue/scala/queries/common/ProgramTimesSubquery.scala +++ b/explore/src/clue/scala/queries/common/ProgramTimesSubquery.scala @@ -13,6 +13,7 @@ object ProgramTimesSubquery extends GraphQLSubquery.Typed[ObservationDB, Program override val subquery: String = s""" { timeEstimateRange $ProgramTimeRangeSubquery + timeEstimateBanded $BandedProgramTimeSubquery timeCharge $BandedProgramTimeSubquery } """ diff --git a/explore/src/main/scala/explore/programs/ProgramDetailsTile.scala b/explore/src/main/scala/explore/programs/ProgramDetailsTile.scala index 91572dfe6f..51cd13b3b0 100644 --- a/explore/src/main/scala/explore/programs/ProgramDetailsTile.scala +++ b/explore/src/main/scala/explore/programs/ProgramDetailsTile.scala @@ -18,7 +18,6 @@ import explore.model.ProgramUser import japgolly.scalajs.react.* import japgolly.scalajs.react.vdom.html_<^.* import lucuma.core.model.Program -import lucuma.core.syntax.display.* import lucuma.core.util.DateInterval import lucuma.react.common.ReactFnComponent import lucuma.react.common.ReactFnProps @@ -26,6 +25,7 @@ import lucuma.refined.* import lucuma.ui.primereact.CheckboxView import lucuma.ui.primereact.FormInfo import lucuma.ui.primereact.given +import lucuma.ui.syntax.all.* case class ProgramDetailsTile( programId: Program.Id, @@ -65,7 +65,7 @@ object ProgramDetailsTile ), <.div( TimeAwardTable(details.allocations), - TimeAccountingTable(props.programTimes) + props.programTimes.renderPot(TimeAccountingTable(_)) ), <.div(ExploreStyles.ProgramDetailsInfoArea)( SupportUsers( diff --git a/explore/src/main/scala/explore/programs/TimeAccountingTable.scala b/explore/src/main/scala/explore/programs/TimeAccountingTable.scala index ced795a9d2..b90107eb0f 100644 --- a/explore/src/main/scala/explore/programs/TimeAccountingTable.scala +++ b/explore/src/main/scala/explore/programs/TimeAccountingTable.scala @@ -3,82 +3,151 @@ package explore.programs +import cats.Monoid import cats.syntax.all.* -import crystal.Pot import explore.components.ui.ExploreStyles +import explore.model.BandedProgramTime import explore.model.ProgramTimes +import explore.model.display.given +import explore.model.reusability.given import japgolly.scalajs.react.* import japgolly.scalajs.react.vdom.html_<^.* +import lucuma.core.enums.ScienceBand +import lucuma.core.syntax.display.* +import lucuma.core.util.Enumerated import lucuma.core.util.TimeSpan -import lucuma.react.common.Css import lucuma.react.common.ReactFnProps +import lucuma.react.syntax.* +import lucuma.react.table.* import lucuma.ui.components.TimeSpanView +import lucuma.ui.format.TimeSpanFormatter import lucuma.ui.syntax.all.* +import lucuma.ui.table.* -case class TimeAccountingTable(programTimes: Pot[ProgramTimes]) +case class TimeAccountingTable(programTimes: ProgramTimes) extends ReactFnProps(TimeAccountingTable.component) object TimeAccountingTable: - private type Props = TimeAccountingTable - - private def table( - headers: List[String], - rows: List[List[TagMod]], - footer: List[List[TagMod]] - ): VdomNode = - // TODO The "pl-react-table" is just used to unify styles (time award table needs it for specificity). - // We will probably change this table to a react-table once we add columns for bands. - // See https://app.shortcut.com/lucuma/story/2947/display-time-award-on-the-program-tab - <.table(ExploreStyles.ProgramTabTable |+| Css("pl-react-table"))( - headers.nonEmpty - .guard[Option] - .as( - <.thead( - <.tr( - headers.toTagMod(h => - <.th( - ^.colSpan := rows.headOption - .filter(_ => headers.length == 1) - .map(_.length) - .getOrElse(1), - h - ) - ) - ) - ) - ), - rows.nonEmpty - .guard[Option] - .as( - <.tbody( - rows.toTagMod(r => <.tr(r.toTagMod(c => <.td(c)))) - ) - ), - footer.nonEmpty - .guard[Option] - .as( - <.tfoot( - footer.toTagMod(r => <.tr(r.toTagMod(c => <.th(c)))) - ) - ) - ) + private type TimeSpanMap = Map[Option[ScienceBand], TimeSpan] + private type DataMap = Map[Option[ScienceBand], Either[TimeSpan, BigDecimal]] + + extension [K, V: Monoid](map: Map[K, V]) def getM(key: K): V = map.getOrElse(key, Monoid[V].empty) + + extension (e: Either[TimeSpan, BigDecimal]) + def toCell: VdomNode = e match + case Left(ts) => TimeSpanView(ts, TimeSpanFormatter.DecimalHours) + case Right(bd) => f"${bd * 100}%.1f%%" + + extension (l: List[BandedProgramTime]) + def toTimeSpanMap: TimeSpanMap = + l.map(bpt => bpt.band -> bpt.time.value).toMap + + private val DataColumnKeys: List[Option[ScienceBand]] = + Enumerated[ScienceBand].all.map(_.some) :+ none + + private case class Row( + label: String, + data: DataMap, + total: Either[TimeSpan, BigDecimal] + ) + + private object Row: + + private def calcTotal(map: TimeSpanMap): TimeSpan = + map.values.toList.combineAll + + private def calcPercent(planned: TimeSpan, used: TimeSpan): BigDecimal = + if (planned.isZero) 0.0 + else used.toMilliseconds / planned.toMilliseconds + + private def fromTimeSpanMap(map: TimeSpanMap, label: String): Row = + val data: DataMap = + DataColumnKeys.map(osb => (osb, map.getM(osb).asLeft)).toMap + val total: TimeSpan = calcTotal(map) + Row(label, data, total.asLeft) + + private def completionRow( + plannedMap: TimeSpanMap, + usedMap: TimeSpanMap + ): Row = + val data: DataMap = + DataColumnKeys + .map(osb => (osb, calcPercent(plannedMap.getM(osb), usedMap.getM(osb)).asRight)) + .toMap + val total: BigDecimal = + calcPercent(calcTotal(plannedMap), calcTotal(usedMap)) + Row("Completion", data, total.asRight) - private val component = ScalaFnComponent[Props]: props => - props.programTimes.renderPot: programTimes => - for - minTime <- programTimes.timeEstimateRange.map(_.minimum.value) - maxTime <- programTimes.timeEstimateRange.map(_.maximum.value) - used = programTimes.fullProgramTime - remain = TimeSpan.Zero // TODO - isSingleTime = minTime == maxTime - yield table( - headers = List("Time Accounting"), - rows = (if (isSingleTime) List(List[TagMod]("Planned", TimeSpanView(minTime))) - else - List( - List[TagMod]("Min Time", TimeSpanView(minTime)), - List[TagMod]("Max Time", TimeSpanView(maxTime)) - )) ++ - List(List[TagMod]("Used", TimeSpanView(used))), - footer = List(List[TagMod]("Remain", TimeSpanView(remain))) - ) + def remainRow(plannedMap: TimeSpanMap, usedMap: TimeSpanMap): Row = + val remainMap: Map[Option[ScienceBand], TimeSpan] = + DataColumnKeys + .map(osb => (osb, plannedMap.getM(osb) -| usedMap.getM(osb))) + .toMap + fromTimeSpanMap(remainMap, "Remain") + + def fromProgramTimes(plannedMap: TimeSpanMap, usedMap: TimeSpanMap): List[Row] = + val planned: Row = fromTimeSpanMap(plannedMap, "Planned") + val used: Row = fromTimeSpanMap(usedMap, "Used") + val completion: Row = completionRow(plannedMap, usedMap) + List(planned, used, completion) + + // A Row is also used for the table metadata for creating the footer + private val ColDef = ColumnDef.WithTableMeta[Row, Row] + + private val LabelColId: ColumnId = ColumnId("label") + private val TotalColId: ColumnId = ColumnId("total") + + private val BandColId: Map[Option[ScienceBand], ColumnId] = + DataColumnKeys.map(osb => (osb, ColumnId(osb.fold("no-band")(_.tag)))).toMap + + private val LabelColumnDef = + ColDef( + LabelColId, + _.label, + "Time Accounting", + footer = _ => "Remain" + ).setSize(200.toPx) + + private def bandColDef(osb: Option[ScienceBand]) = + ColDef( + BandColId(osb), + _.data(osb), + header = osb.fold("No Band")(_.shortName), + cell = _.value.toCell, + footer = _.table.options.meta.fold(EmptyVdom)(_.data(osb).toCell) + ).setSize(90.toPx) + + private val TotalColDef = + ColDef( + TotalColId, + _.total, + "Total", + cell = _.value.toCell, + footer = _.table.options.meta.fold(EmptyVdom)(_.total.toCell) + ).setSize(90.toPx) + + private val Columns: Reusable[List[ColumnDef.WithTableMeta[Row, ?, Row]]] = + Reusable.always: + LabelColumnDef +: DataColumnKeys.map(bandColDef) :+ TotalColDef + + private val component = ScalaFnComponent[TimeAccountingTable]: props => + for { + plannedMap <- useMemo(props.programTimes.timeEstimateBanded)(_.toTimeSpanMap) + usedMap <- useMemo(props.programTimes.timeCharge)(_.toTimeSpanMap) + rows <- useMemo((plannedMap, usedMap)): (planned, used) => + Row.fromProgramTimes(planned, used) + remainRow <- useMemo((plannedMap, usedMap)): (planned, used) => + Row.remainRow(planned, used) + table <- useReactTable: + TableOptions( + Columns, + rows, + getRowId = (row, _, _) => RowId(row.label), + meta = remainRow, + enableSorting = false, + enableColumnResizing = false + ) + } yield PrimeTable( + table, + tableMod = ExploreStyles.ProgramTabTable + ) diff --git a/explore/src/main/scala/explore/programs/TimeAwardTable.scala b/explore/src/main/scala/explore/programs/TimeAwardTable.scala index 2a6018e433..7e0ba6f02c 100644 --- a/explore/src/main/scala/explore/programs/TimeAwardTable.scala +++ b/explore/src/main/scala/explore/programs/TimeAwardTable.scala @@ -28,8 +28,6 @@ case class TimeAwardTable(allocations: CategoryAllocationList) extends ReactFnProps(TimeAwardTable.component) object TimeAwardTable: - private type Props = TimeAwardTable - private case class Row(category: TimeAccountingCategory, allocations: BandAllocations): lazy val categoryTotal: TimeSpan = allocations.value.values.toList.combineAll @@ -66,7 +64,7 @@ object TimeAwardTable: "Time Award", cell = cell => <.div(cell.value.description, cell.value.renderFlag), footer = _ => "Total" - ).setSize(90.toPx) + ).setSize(200.toPx) private def bandColDef(band: ScienceBand) = ColDef( @@ -99,20 +97,19 @@ object TimeAwardTable: partnerColDef +: Enumerated[ScienceBand].all.map(bandColDef) :+ totalColDef private val component = - ScalaFnComponent - .withHooks[Props] - .useMemoBy(props => props.allocations)(_ => Row.fromCategoryAllocationList) - .useReactTableBy: (props, rows) => - TableOptions( - columns, - rows, - getRowId = (row, _, _) => RowId(row._1.tag), - meta = TableMeta.fromCategoryAllocationList(props.allocations), - enableSorting = false, - enableColumnResizing = false - ) - .render: (props, _, table) => - PrimeTable( - table, - tableMod = ExploreStyles.ProgramTabTable - ) + ScalaFnComponent[TimeAwardTable]: props => + for { + rows <- useMemo(props.allocations)(Row.fromCategoryAllocationList) + table <- useReactTable: + TableOptions( + columns, + rows, + getRowId = (row, _, _) => RowId(row._1.tag), + meta = TableMeta.fromCategoryAllocationList(props.allocations), + enableSorting = false, + enableColumnResizing = false + ) + } yield PrimeTable( + table, + tableMod = ExploreStyles.ProgramTabTable + ) diff --git a/model/shared/src/main/scala/explore/model/ProgramTimes.scala b/model/shared/src/main/scala/explore/model/ProgramTimes.scala index 6dd248b90f..e1da71469a 100644 --- a/model/shared/src/main/scala/explore/model/ProgramTimes.scala +++ b/model/shared/src/main/scala/explore/model/ProgramTimes.scala @@ -9,8 +9,9 @@ import io.circe.Decoder import lucuma.core.util.TimeSpan case class ProgramTimes( - timeEstimateRange: Option[ProgramTimeRange], - timeCharge: List[BandedProgramTime] + timeEstimateRange: Option[ProgramTimeRange], + timeEstimateBanded: List[BandedProgramTime], + timeCharge: List[BandedProgramTime] ) derives Eq, Decoder: val fullProgramTime: TimeSpan =