Skip to content

Commit

Permalink
Implement chart filtering
Browse files Browse the repository at this point in the history
  • Loading branch information
tbidne committed Dec 16, 2024
1 parent 58e3152 commit b86c733
Show file tree
Hide file tree
Showing 12 changed files with 276 additions and 65 deletions.
22 changes: 12 additions & 10 deletions backend/data/input/example/chart-requests.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@
title = 'Runs by distance'
y-axis = 'distance'

# Only takes runs with label 'label1'
# Only takes runs with label 'marathon'
[[charts]]
filters = ['label1']
title = 'Runs by duration'
title = 'Marathons'
filters = ['marathon']
y-axis = 'duration'
y-axis1 = 'distance'

# Only takes runs with all of the following conditions:
#
# 1. Has label 'label1'
# 2. Has label 'label2' OR does not have label 'label3'
# Takes runs w/ label 'official' but __not__ marathon.
[[charts]]
filters = ['label1', 'or (label2) (not (label3))']
title = 'Runs by pace'
title = 'Official non-marathons'
filters = ['official', 'not (marathon)']
y-axis = 'pace'

# Takes half-marathon and marathon
[[charts]]
title = 'Marathons and half-marathons'
filters = ['or (half-marathon) (marathon)']
y-axis = 'pace'
16 changes: 15 additions & 1 deletion backend/data/input/example/runs.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,25 @@ duration = '20m30s'
datetime = 2024-10-20T14:30:00
distance = '20 miles'
duration = '2h40m54s'
labels = []
labels = ['label1']

[[runs]]
datetime = 2024-10-25T12:00:00-08:00
distance = 'marathon'
duration = '3h20m'
labels = ['official', 'marathon']
title= 'Some Marathon'

[[runs]]
datetime = 2024-10-28T12:00:00-08:00
distance = 'marathon'
duration = '315m'
labels = ['official', 'marathon']
title= 'Another marathon'

[[runs]]
datetime = 2024-10-10T12:00:00-08:00
distance = 'half-marathon'
duration = '15m'
labels = ['official', 'half-marathon']
title= 'Some half marathon'
12 changes: 11 additions & 1 deletion backend/src/Pacer/Chart.hs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,17 @@ createChartSeq runsPath chartRequestsPath = do
runs <- readDecodeToml @(SomeRuns Double) runsPath
chartRequests <- readDecodeToml @ChartRequests chartRequestsPath

pure $ Chart.mkCharts runs chartRequests
let titlesOrCharts = Chart.mkCharts runs chartRequests

for titlesOrCharts $ \case
Right c -> pure c
Left t ->
throwText
$ mconcat
[ "Chart with title '",
t,
"' is empty due to all runs being filtered out."
]
where
readDecodeToml :: forall a. (DecodeTOML a) => OsPath -> IO a
readDecodeToml = failMapLeft displayException . decode <=< readFileUtf8
Expand Down
61 changes: 39 additions & 22 deletions backend/src/Pacer/Chart/Data/Chart.hs
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,22 @@ where

import Data.Aeson (KeyValue ((.=)), ToJSON (toJSON))
import Data.Aeson qualified as Asn
import Data.Foldable1 (Foldable1 (foldMap1))
import Data.List (all)
import Data.List qualified as L
import Data.Sequence qualified as Seq
import Data.Sequence (Seq (Empty))
import Data.Sequence.NonEmpty qualified as NESeq
import Pacer.Chart.Data.ChartRequest
( ChartRequest (title, yAxis, yAxis1),
( ChartRequest (filters, title, yAxis, yAxis1),
ChartRequests (unChartRequests),
FilterExpr,
FilterType (FilterLabel),
YAxisType
( YAxisDistance,
YAxisDuration,
YAxisPace
),
eval,
)
import Pacer.Chart.Data.Run
( Run (datetime, distance, duration),
Expand Down Expand Up @@ -62,7 +68,7 @@ instance ToJSON Chart where
-- | Data for a chart with a single Y axis.
data ChartY = MkChartY
{ -- | X and Y axis data.
values :: Seq (Tuple2 RunTimestamp Double),
values :: NESeq (Tuple2 RunTimestamp Double),
-- | Y label.
yLabel :: Text
}
Expand All @@ -84,7 +90,7 @@ instance ToJSON ChartY where
-- | Data for a chart with two Y axes.
data ChartY1 = MkChartY1
{ -- | Data for a chart with two y Axes.
values :: Seq (Tuple3 RunTimestamp Double Double),
values :: NESeq (Tuple3 RunTimestamp Double Double),
-- | Label for first Y axis.
yLabel :: Text,
-- | Label for second Y axis.
Expand All @@ -111,10 +117,10 @@ instance ToJSON ChartY1 where
(x, y, y1) = L.unzip3 $ toList c.values

-- | Accumulator for chart with a single Y axis.
type AccY = Seq (Tuple2 RunTimestamp Double)
type AccY = NESeq (Tuple2 RunTimestamp Double)

-- | Accumulator for chart with two Y axes.
type AccY1 = Seq (Tuple3 RunTimestamp Double Double)
type AccY1 = NESeq (Tuple3 RunTimestamp Double Double)

-- | Turns a sequence of runs and chart requests into charts.
mkCharts ::
Expand All @@ -126,7 +132,7 @@ mkCharts ::
) =>
SomeRuns a ->
ChartRequests ->
Seq Chart
Seq (Either Text Chart)
mkCharts runs = fmap (mkChart runs) . (.unChartRequests)

-- NOTE: HLint incorrectly thinks some brackets are unnecessary.
Expand All @@ -143,34 +149,36 @@ mkChart ::
Show a,
ToReal a
) =>
-- | List of runs.
SomeRuns a ->
-- | Chart request.
ChartRequest ->
Chart
-- | Chart result. Nothing if no runs passed the request's filter.
Either Text Chart
mkChart (MkSomeRuns someRuns@((MkSomeRun @distUnit sd _) :<|| _)) request =
MkChart
{ chartData,
title = request.title
}
case filteredRuns of
Empty -> Left request.title
r :<| rs -> Right $ MkChart (mkChartData (r :<|| rs)) request.title
where
filteredRuns = filterRuns someRuns request.filters

finalDistUnit :: DistanceUnit
finalDistUnit = case sd of
SMeter -> Kilometer
SKilometer -> Kilometer
SMile -> Mile

chartData :: ChartData
chartData = case request.yAxis1 of
mkChartData :: NESeq (SomeRun a) -> ChartData
mkChartData runs = case request.yAxis1 of
Nothing ->
let vals = withSingI sd $ foldMap toAccY someRuns
let vals = withSingI sd $ foldMap1 toAccY runs
lbl = mkYLabel request.yAxis
chartY = MkChartY vals lbl
in MkChartDataY chartY
in MkChartDataY (MkChartY vals lbl)
Just yAxis1 ->
let vals = withSingI sd $ foldMap (toAccY1 yAxis1) someRuns
let vals = withSingI sd $ foldMap1 (toAccY1 yAxis1) runs
lbl = mkYLabel request.yAxis
lbl1 = mkYLabel yAxis1
chartY1 = MkChartY1 vals lbl lbl1
in MkChartDataY1 chartY1
in MkChartDataY1 (MkChartY1 vals lbl lbl1)

mkYLabel :: YAxisType -> Text
mkYLabel = \case
Expand All @@ -181,11 +189,11 @@ mkChart (MkSomeRuns someRuns@((MkSomeRun @distUnit sd _) :<|| _)) request =
dstTxt = display $ withSingI sd $ fromSingI @_ @distUnit

toAccY :: SomeRun a -> AccY
toAccY sr@(MkSomeRun _ r) = Seq.singleton (r.datetime, toY sr)
toAccY sr@(MkSomeRun _ r) = NESeq.singleton (r.datetime, toY sr)

toAccY1 :: YAxisType -> SomeRun a -> AccY1
toAccY1 yAxisType sr@(MkSomeRun _ r) =
Seq.singleton (r.datetime, toY sr, toYHelper yAxisType sr)
NESeq.singleton (r.datetime, toY sr, toYHelper yAxisType sr)

toY :: SomeRun a -> Double
toY = toYHelper request.yAxis
Expand All @@ -211,3 +219,12 @@ mkChart (MkSomeRuns someRuns@((MkSomeRun @distUnit sd _) :<|| _)) request =
runUnits.distance
((.unPositive) <$> runUnits.duration)
in pace.unPace.unDuration

filterRuns :: NESeq (SomeRun a) -> List FilterExpr -> Seq (SomeRun a)
filterRuns rs filters = NESeq.filter filterRun rs
where
filterRun :: SomeRun a -> Bool
filterRun r = all (eval (applyFilter r)) filters

applyFilter :: SomeRun a -> FilterType -> Bool
applyFilter (MkSomeRun _ r) (FilterLabel lbl) = lbl `elem` r.labels
22 changes: 22 additions & 0 deletions backend/src/Pacer/Chart/Data/ChartRequest.hs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module Pacer.Chart.Data.ChartRequest
ChartRequest (..),
FilterType (..),
Expr (..),
eval,
FilterExpr,
YAxisType (..),

Expand Down Expand Up @@ -65,8 +66,20 @@ data Expr a
Not (Expr a) -- "not (expr)"
| -- | "or (expr) (expr)""
Or (Expr a) (Expr a)
| -- | To keep the expression simple, we don't actually parse AND
-- (AND is represented by multiple filters). Its inclusion here is purely
-- to represent Xor.
And (Expr a) (Expr a)
deriving stock (Eq, Show)

eval :: (a -> Bool) -> Expr a -> Bool
eval p = go
where
go (Atom x) = p x
go (Not e) = not (go e)
go (Or e1 e2) = go e1 || go e2
go (And e1 e2) = go e1 && go e2

-- | Alias for a filter expression.
type FilterExpr = Expr FilterType

Expand All @@ -77,6 +90,7 @@ instance (Parser a) => Parser (Expr a) where
MP.choice
[ parseNot,
parseOr,
parseXor,
parseAtom
]

Expand All @@ -98,6 +112,14 @@ instance (Parser a) => Parser (Expr a) where
MPC.char ')'
pure $ Or f1 f2

parseXor = do
MPC.string "xor ("
f1 <- parseFilter
MPC.string ") ("
f2 <- parseFilter
MPC.char ')'
pure $ Or (And f1 (Not f2)) (And (Not f1) f2)

parseAtom = Atom <$> P.parser

instance (Parser a) => DecodeTOML (Expr a) where
Expand Down
2 changes: 1 addition & 1 deletion backend/src/Pacer/Prelude.hs
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ import Data.Text.Lazy qualified as TL
import Data.Text.Lazy.Builder qualified as TLB
import Data.Time.Calendar as X (Day)
import Data.Time.LocalTime as X (LocalTime, ZonedTime)
import Data.Traversable as X (Traversable (sequenceA, traverse))
import Data.Traversable as X (Traversable (sequenceA, traverse), for)
import Data.Tuple as X (fst, snd)
#if MIN_VERSION_base(4, 20, 0)
import Data.Tuple.Experimental as X (Tuple2, Tuple3, Tuple4)
Expand Down
6 changes: 5 additions & 1 deletion backend/test/functional/Functional/Chart.hs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ tests =
testGroup
"Pacer.Chart"
[ testExampleChart,
testSimple
testSimple,
testFilter
]

testExampleChart :: TestTree
Expand All @@ -31,3 +32,6 @@ testExampleChart = testGoldenParams params

testSimple :: TestTree
testSimple = testChart "Simple example" [osp|testSimple|]

testFilter :: TestTree
testFilter = testChart "Filter example" [osp|testFilter|]
24 changes: 24 additions & 0 deletions backend/test/functional/data/testFilter_chart-requests.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[[charts]]
title = 'Chart 1'
y-axis = 'distance'

[[charts]]
title = 'Chart 2'
y-axis = 'duration'
y-axis1 = 'distance'
filters = ['label1']

[[charts]]
title = 'Chart 3'
y-axis = 'pace'
filters = ['label1', 'or (label2) (label3)']

[[charts]]
title = 'Chart 4'
y-axis = 'pace'
filters = ['label3', 'not (label4)']

[[charts]]
title = 'Chart 5'
filters = ['xor (label3) (label4)']
y-axis = 'pace'
28 changes: 28 additions & 0 deletions backend/test/functional/data/testFilter_runs.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[[runs]]
datetime = 2024-10-20
distance = '5 km'
duration = '20m30s'

[[runs]]
datetime = 2024-10-20T14:30:00
distance = '20 miles'
duration = '2h40m54s'
labels = ['label1']

[[runs]]
datetime = 2024-10-25T12:00:00-08:00
distance = 'marathon'
duration = '3h20m'
labels = ['label1', 'label2']

[[runs]]
datetime = 2024-10-26T12:00:00-08:00
distance = '10 kilometers'
duration = '1h'
labels = ['label3']

[[runs]]
datetime = 2024-10-26T12:00:00-08:00
distance = '10 kilometers'
duration = '1h'
labels = ['label3', 'label4']
7 changes: 0 additions & 7 deletions backend/test/functional/data/testSimple_chart-requests.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,11 @@
title = 'Runs by distance'
y-axis = 'distance'

# Only takes runs with label 'label1'
[[charts]]
filters = ['label1']
title = 'Runs by duration'
y-axis = 'duration'
y-axis1 = 'distance'

# Only takes runs with all of the following conditions:
#
# 1. Has label 'label1'
# 2. Has label 'label2' OR does not have label 'label3'
[[charts]]
filters = ['label1', 'or (label2) (not (label3))']
title = 'Runs by pace'
y-axis = 'pace'
Loading

0 comments on commit b86c733

Please sign in to comment.