Skip to content

Commit

Permalink
Merge pull request #4556 from gemini-hlsw/sc-3721-display-time-accoun…
Browse files Browse the repository at this point in the history
…ting-by-band
  • Loading branch information
toddburnside authored Feb 13, 2025
2 parents 9fa5afb + 0674965 commit 458b4a2
Show file tree
Hide file tree
Showing 6 changed files with 156 additions and 89 deletions.
2 changes: 1 addition & 1 deletion common/src/main/scala/explore/model/reusability.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

/**
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ object ProgramTimesSubquery extends GraphQLSubquery.Typed[ObservationDB, Program
override val subquery: String = s"""
{
timeEstimateRange $ProgramTimeRangeSubquery
timeEstimateBanded $BandedProgramTimeSubquery
timeCharge $BandedProgramTimeSubquery
}
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ 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
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,
Expand Down Expand Up @@ -65,7 +65,7 @@ object ProgramDetailsTile
),
<.div(
TimeAwardTable(details.allocations),
TimeAccountingTable(props.programTimes)
props.programTimes.renderPot(TimeAccountingTable(_))
),
<.div(ExploreStyles.ProgramDetailsInfoArea)(
SupportUsers(
Expand Down
196 changes: 132 additions & 64 deletions explore/src/main/scala/explore/programs/TimeAccountingTable.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,81 +4,149 @@
package explore.programs

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 (e: Either[TimeSpan, BigDecimal])
private def toCell: VdomNode = e match
case Left(ts) => TimeSpanView(ts, TimeSpanFormatter.DecimalHours)
case Right(bd) => f"${bd * 100}%.1f%%"

extension (l: List[BandedProgramTime])
private 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.get(osb).orEmpty.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.get(osb).orEmpty, usedMap.get(osb).orEmpty).asRight)
)
)
)
.toMap
val total: BigDecimal =
calcPercent(calcTotal(plannedMap), calcTotal(usedMap))
Row("Completion", data, total.asRight)

def remainRow(plannedMap: TimeSpanMap, usedMap: TimeSpanMap): Row =
val remainMap: Map[Option[ScienceBand], TimeSpan] =
DataColumnKeys
.map(osb => (osb, plannedMap.get(osb).orEmpty -| usedMap.get(osb).orEmpty))
.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 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)))
)
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
)
37 changes: 17 additions & 20 deletions explore/src/main/scala/explore/programs/TimeAwardTable.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
)
5 changes: 3 additions & 2 deletions model/shared/src/main/scala/explore/model/ProgramTimes.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down

0 comments on commit 458b4a2

Please sign in to comment.