2022-10-04 19:55:51 +00:00
|
|
|
import { ReactNode, SVGProps, memo, useRef, useEffect, useMemo } from 'react'
|
2022-09-29 04:43:04 +00:00
|
|
|
import { pointer, select } from 'd3-selection'
|
2022-09-29 19:51:38 +00:00
|
|
|
import { Axis, AxisScale } from 'd3-axis'
|
2022-09-28 08:00:39 +00:00
|
|
|
import { brushX, D3BrushEvent } from 'd3-brush'
|
2022-10-03 16:31:07 +00:00
|
|
|
import { area, line, CurveFactory } from 'd3-shape'
|
2022-09-28 03:24:42 +00:00
|
|
|
import { nanoid } from 'nanoid'
|
2022-09-29 04:14:34 +00:00
|
|
|
import dayjs from 'dayjs'
|
2022-09-28 03:24:42 +00:00
|
|
|
import clsx from 'clsx'
|
|
|
|
|
|
|
|
import { Contract } from 'common/contract'
|
2022-10-01 07:10:17 +00:00
|
|
|
import { useMeasureSize } from 'web/hooks/use-measure-size'
|
2022-10-05 12:57:45 +00:00
|
|
|
import { useIsMobile } from 'web/hooks/use-is-mobile'
|
2022-09-28 03:24:42 +00:00
|
|
|
|
2022-09-30 23:16:04 +00:00
|
|
|
export type Point<X, Y, T = unknown> = { x: X; y: Y; obj?: T }
|
|
|
|
|
|
|
|
export interface ContinuousScale<T> extends AxisScale<T> {
|
|
|
|
invert(n: number): T
|
|
|
|
}
|
|
|
|
|
2022-09-29 19:51:38 +00:00
|
|
|
export type XScale<P> = P extends Point<infer X, infer _> ? AxisScale<X> : never
|
|
|
|
export type YScale<P> = P extends Point<infer _, infer Y> ? AxisScale<Y> : never
|
|
|
|
|
2022-10-04 08:18:22 +00:00
|
|
|
export type Margin = {
|
|
|
|
top: number
|
|
|
|
right: number
|
|
|
|
bottom: number
|
|
|
|
left: number
|
|
|
|
}
|
2022-09-28 03:24:42 +00:00
|
|
|
|
|
|
|
export const XAxis = <X,>(props: { w: number; h: number; axis: Axis<X> }) => {
|
|
|
|
const { h, axis } = props
|
|
|
|
const axisRef = useRef<SVGGElement>(null)
|
|
|
|
useEffect(() => {
|
|
|
|
if (axisRef.current != null) {
|
|
|
|
select(axisRef.current)
|
|
|
|
.transition()
|
|
|
|
.duration(250)
|
|
|
|
.call(axis)
|
|
|
|
.select('.domain')
|
|
|
|
.attr('stroke-width', 0)
|
|
|
|
}
|
|
|
|
}, [h, axis])
|
|
|
|
return <g ref={axisRef} transform={`translate(0, ${h})`} />
|
|
|
|
}
|
|
|
|
|
|
|
|
export const YAxis = <Y,>(props: { w: number; h: number; axis: Axis<Y> }) => {
|
|
|
|
const { w, h, axis } = props
|
|
|
|
const axisRef = useRef<SVGGElement>(null)
|
|
|
|
useEffect(() => {
|
|
|
|
if (axisRef.current != null) {
|
|
|
|
select(axisRef.current)
|
|
|
|
.call(axis)
|
|
|
|
.call((g) =>
|
|
|
|
g.selectAll('.tick line').attr('x2', w).attr('stroke-opacity', 0.1)
|
|
|
|
)
|
|
|
|
.select('.domain')
|
|
|
|
.attr('stroke-width', 0)
|
|
|
|
}
|
|
|
|
}, [w, h, axis])
|
|
|
|
return <g ref={axisRef} />
|
|
|
|
}
|
|
|
|
|
|
|
|
const LinePathInternal = <P,>(
|
|
|
|
props: {
|
|
|
|
data: P[]
|
|
|
|
px: number | ((p: P) => number)
|
|
|
|
py: number | ((p: P) => number)
|
2022-10-03 16:31:07 +00:00
|
|
|
curve: CurveFactory
|
2022-09-28 03:24:42 +00:00
|
|
|
} & SVGProps<SVGPathElement>
|
|
|
|
) => {
|
|
|
|
const { data, px, py, curve, ...rest } = props
|
2022-10-03 16:31:07 +00:00
|
|
|
const d3Line = line<P>(px, py).curve(curve)
|
2022-09-28 03:24:42 +00:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
|
|
return <path {...rest} fill="none" d={d3Line(data)!} />
|
|
|
|
}
|
|
|
|
export const LinePath = memo(LinePathInternal) as typeof LinePathInternal
|
|
|
|
|
|
|
|
const AreaPathInternal = <P,>(
|
|
|
|
props: {
|
|
|
|
data: P[]
|
|
|
|
px: number | ((p: P) => number)
|
|
|
|
py0: number | ((p: P) => number)
|
|
|
|
py1: number | ((p: P) => number)
|
2022-10-03 16:31:07 +00:00
|
|
|
curve: CurveFactory
|
2022-09-28 03:24:42 +00:00
|
|
|
} & SVGProps<SVGPathElement>
|
|
|
|
) => {
|
|
|
|
const { data, px, py0, py1, curve, ...rest } = props
|
2022-10-03 16:31:07 +00:00
|
|
|
const d3Area = area<P>(px, py0, py1).curve(curve)
|
2022-09-28 03:24:42 +00:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
|
|
return <path {...rest} d={d3Area(data)!} />
|
|
|
|
}
|
|
|
|
export const AreaPath = memo(AreaPathInternal) as typeof AreaPathInternal
|
|
|
|
|
|
|
|
export const AreaWithTopStroke = <P,>(props: {
|
|
|
|
data: P[]
|
2022-10-04 08:18:22 +00:00
|
|
|
color: string
|
2022-09-28 03:24:42 +00:00
|
|
|
px: number | ((p: P) => number)
|
|
|
|
py0: number | ((p: P) => number)
|
|
|
|
py1: number | ((p: P) => number)
|
2022-10-03 16:31:07 +00:00
|
|
|
curve: CurveFactory
|
2022-09-28 03:24:42 +00:00
|
|
|
}) => {
|
2022-10-04 08:18:22 +00:00
|
|
|
const { data, color, px, py0, py1, curve } = props
|
2022-09-28 03:24:42 +00:00
|
|
|
return (
|
|
|
|
<g>
|
|
|
|
<AreaPath
|
|
|
|
data={data}
|
|
|
|
px={px}
|
|
|
|
py0={py0}
|
|
|
|
py1={py1}
|
|
|
|
curve={curve}
|
|
|
|
fill={color}
|
2022-09-30 07:03:31 +00:00
|
|
|
opacity={0.2}
|
2022-09-28 03:24:42 +00:00
|
|
|
/>
|
|
|
|
<LinePath data={data} px={px} py={py1} curve={curve} stroke={color} />
|
|
|
|
</g>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2022-10-04 19:55:51 +00:00
|
|
|
export const SliceMarker = (props: {
|
|
|
|
color: string
|
|
|
|
x: number
|
|
|
|
y0: number
|
|
|
|
y1: number
|
|
|
|
}) => {
|
|
|
|
const { color, x, y0, y1 } = props
|
|
|
|
return (
|
|
|
|
<g>
|
2022-10-04 20:41:48 +00:00
|
|
|
<line stroke="white" strokeWidth={1} x1={x} x2={x} y1={y0} y2={y1} />
|
2022-10-04 19:55:51 +00:00
|
|
|
<circle
|
|
|
|
stroke="white"
|
2022-10-04 20:41:48 +00:00
|
|
|
strokeWidth={1}
|
2022-10-04 19:55:51 +00:00
|
|
|
fill={color}
|
|
|
|
cx={x}
|
|
|
|
cy={y1}
|
|
|
|
r={5}
|
|
|
|
/>
|
|
|
|
</g>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2022-09-30 23:16:04 +00:00
|
|
|
export const SVGChart = <X, TT>(props: {
|
2022-09-28 03:24:42 +00:00
|
|
|
children: ReactNode
|
|
|
|
w: number
|
|
|
|
h: number
|
2022-10-04 08:18:22 +00:00
|
|
|
margin: Margin
|
2022-09-28 03:24:42 +00:00
|
|
|
xAxis: Axis<X>
|
2022-09-29 19:51:38 +00:00
|
|
|
yAxis: Axis<number>
|
2022-10-04 21:02:44 +00:00
|
|
|
ttParams: TooltipParams<TT> | undefined
|
2022-09-28 03:24:42 +00:00
|
|
|
onSelect?: (ev: D3BrushEvent<any>) => void
|
2022-10-04 19:55:51 +00:00
|
|
|
onMouseOver?: (mouseX: number, mouseY: number) => void
|
|
|
|
onMouseLeave?: () => void
|
2022-09-30 23:16:04 +00:00
|
|
|
Tooltip?: TooltipComponent<X, TT>
|
2022-09-28 03:24:42 +00:00
|
|
|
}) => {
|
2022-10-04 08:18:22 +00:00
|
|
|
const {
|
|
|
|
children,
|
|
|
|
w,
|
|
|
|
h,
|
|
|
|
margin,
|
|
|
|
xAxis,
|
|
|
|
yAxis,
|
2022-10-04 21:02:44 +00:00
|
|
|
ttParams,
|
2022-10-04 08:18:22 +00:00
|
|
|
onSelect,
|
2022-10-04 19:55:51 +00:00
|
|
|
onMouseOver,
|
|
|
|
onMouseLeave,
|
2022-10-04 08:18:22 +00:00
|
|
|
Tooltip,
|
|
|
|
} = props
|
2022-10-01 07:10:17 +00:00
|
|
|
const tooltipMeasure = useMeasureSize()
|
2022-09-28 03:24:42 +00:00
|
|
|
const overlayRef = useRef<SVGGElement>(null)
|
2022-10-04 08:18:22 +00:00
|
|
|
const innerW = w - (margin.left + margin.right)
|
|
|
|
const innerH = h - (margin.top + margin.bottom)
|
2022-09-28 03:24:42 +00:00
|
|
|
const clipPathId = useMemo(() => nanoid(), [])
|
2022-10-05 12:57:45 +00:00
|
|
|
const isMobile = useIsMobile()
|
2022-09-28 03:24:42 +00:00
|
|
|
|
|
|
|
const justSelected = useRef(false)
|
|
|
|
useEffect(() => {
|
|
|
|
if (onSelect != null && overlayRef.current) {
|
|
|
|
const brush = brushX().extent([
|
|
|
|
[0, 0],
|
|
|
|
[innerW, innerH],
|
|
|
|
])
|
|
|
|
brush.on('end', (ev) => {
|
|
|
|
// when we clear the brush after a selection, that would normally cause
|
|
|
|
// another 'end' event, so we have to suppress it with this flag
|
|
|
|
if (!justSelected.current) {
|
|
|
|
justSelected.current = true
|
|
|
|
onSelect(ev)
|
2022-10-04 19:55:51 +00:00
|
|
|
onMouseLeave?.()
|
2022-09-28 03:24:42 +00:00
|
|
|
if (overlayRef.current) {
|
|
|
|
select(overlayRef.current).call(brush.clear)
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
justSelected.current = false
|
|
|
|
}
|
|
|
|
})
|
2022-09-28 07:58:51 +00:00
|
|
|
// mqp: shape-rendering null overrides the default d3-brush shape-rendering
|
|
|
|
// of `crisp-edges`, which seems to cause graphical glitches on Chrome
|
|
|
|
// (i.e. the bug where the area fill flickers white)
|
|
|
|
select(overlayRef.current)
|
|
|
|
.call(brush)
|
|
|
|
.select('.selection')
|
|
|
|
.attr('shape-rendering', 'null')
|
2022-09-28 03:24:42 +00:00
|
|
|
}
|
2022-10-04 19:55:51 +00:00
|
|
|
}, [innerW, innerH, onSelect, onMouseLeave])
|
2022-09-28 03:24:42 +00:00
|
|
|
|
2022-09-29 04:43:04 +00:00
|
|
|
const onPointerMove = (ev: React.PointerEvent) => {
|
|
|
|
if (ev.pointerType === 'mouse' && onMouseOver) {
|
2022-09-30 23:16:04 +00:00
|
|
|
const [x, y] = pointer(ev)
|
2022-10-04 19:55:51 +00:00
|
|
|
onMouseOver(x, y)
|
2022-09-29 04:43:04 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-10-05 12:57:45 +00:00
|
|
|
const onTouchMove = (ev: React.TouchEvent) => {
|
|
|
|
if (onMouseOver) {
|
|
|
|
const touch = ev.touches[0]
|
|
|
|
const x = touch.pageX - ev.currentTarget.getBoundingClientRect().left
|
|
|
|
const y = touch.pageY - ev.currentTarget.getBoundingClientRect().top
|
|
|
|
onMouseOver(x, y)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-09-29 04:43:04 +00:00
|
|
|
const onPointerLeave = () => {
|
2022-10-04 19:55:51 +00:00
|
|
|
onMouseLeave?.()
|
2022-09-29 04:43:04 +00:00
|
|
|
}
|
|
|
|
|
2022-09-28 03:24:42 +00:00
|
|
|
return (
|
2022-10-01 07:10:17 +00:00
|
|
|
<div className="relative overflow-hidden">
|
2022-10-04 21:02:44 +00:00
|
|
|
{ttParams && Tooltip && (
|
2022-09-30 23:16:04 +00:00
|
|
|
<TooltipContainer
|
2022-10-01 07:10:17 +00:00
|
|
|
setElem={tooltipMeasure.setElem}
|
2022-10-04 08:18:22 +00:00
|
|
|
margin={margin}
|
2022-10-01 07:10:17 +00:00
|
|
|
pos={getTooltipPosition(
|
2022-10-04 21:02:44 +00:00
|
|
|
ttParams.x,
|
|
|
|
ttParams.y,
|
2022-10-01 07:10:17 +00:00
|
|
|
innerW,
|
|
|
|
innerH,
|
2022-10-05 13:19:26 +00:00
|
|
|
tooltipMeasure.width ?? 140,
|
|
|
|
tooltipMeasure.height ?? 35,
|
|
|
|
isMobile ?? false
|
2022-10-01 07:10:17 +00:00
|
|
|
)}
|
2022-09-30 23:16:04 +00:00
|
|
|
>
|
|
|
|
<Tooltip
|
|
|
|
xScale={xAxis.scale()}
|
2022-10-04 21:02:44 +00:00
|
|
|
x={ttParams.x}
|
|
|
|
y={ttParams.y}
|
|
|
|
data={ttParams.data}
|
2022-09-30 23:16:04 +00:00
|
|
|
/>
|
2022-09-29 04:43:04 +00:00
|
|
|
</TooltipContainer>
|
|
|
|
)}
|
2022-09-30 05:45:31 +00:00
|
|
|
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
|
2022-09-29 04:43:04 +00:00
|
|
|
<clipPath id={clipPathId}>
|
|
|
|
<rect x={0} y={0} width={innerW} height={innerH} />
|
|
|
|
</clipPath>
|
2022-10-04 08:18:22 +00:00
|
|
|
<g transform={`translate(${margin.left}, ${margin.top})`}>
|
2022-09-29 04:43:04 +00:00
|
|
|
<XAxis axis={xAxis} w={innerW} h={innerH} />
|
|
|
|
<YAxis axis={yAxis} w={innerW} h={innerH} />
|
|
|
|
<g clipPath={`url(#${clipPathId})`}>{children}</g>
|
2022-10-05 12:57:45 +00:00
|
|
|
{!isMobile ? (
|
|
|
|
<g
|
|
|
|
ref={overlayRef}
|
|
|
|
x="0"
|
|
|
|
y="0"
|
|
|
|
width={innerW}
|
|
|
|
height={innerH}
|
|
|
|
fill="none"
|
|
|
|
pointerEvents="all"
|
|
|
|
onPointerEnter={onPointerMove}
|
|
|
|
onPointerMove={onPointerMove}
|
|
|
|
onPointerLeave={onPointerLeave}
|
|
|
|
/>
|
|
|
|
) : (
|
|
|
|
<rect
|
|
|
|
x="0"
|
|
|
|
y="0"
|
|
|
|
width={innerW}
|
|
|
|
height={innerH}
|
|
|
|
fill="transparent"
|
|
|
|
onTouchMove={onTouchMove}
|
|
|
|
onTouchEnd={onPointerLeave}
|
|
|
|
/>
|
|
|
|
)}
|
2022-09-29 04:43:04 +00:00
|
|
|
</g>
|
|
|
|
</svg>
|
|
|
|
</div>
|
2022-09-28 03:24:42 +00:00
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2022-10-01 07:10:17 +00:00
|
|
|
export type TooltipPosition = { left: number; bottom: number }
|
2022-09-30 05:45:31 +00:00
|
|
|
|
|
|
|
export const getTooltipPosition = (
|
|
|
|
mouseX: number,
|
|
|
|
mouseY: number,
|
2022-10-01 07:10:17 +00:00
|
|
|
containerWidth: number,
|
|
|
|
containerHeight: number,
|
2022-10-05 13:19:26 +00:00
|
|
|
tooltipWidth: number,
|
|
|
|
tooltipHeight: number,
|
|
|
|
isMobile: boolean
|
2022-09-30 05:45:31 +00:00
|
|
|
) => {
|
2022-10-01 07:10:17 +00:00
|
|
|
let left = mouseX + 12
|
2022-10-05 13:19:26 +00:00
|
|
|
let bottom = !isMobile
|
|
|
|
? containerHeight - mouseY + 12
|
|
|
|
: containerHeight - tooltipHeight + 12
|
2022-10-01 07:10:17 +00:00
|
|
|
if (tooltipWidth != null) {
|
|
|
|
const overflow = left + tooltipWidth - containerWidth
|
|
|
|
if (overflow > 0) {
|
|
|
|
left -= overflow
|
|
|
|
}
|
2022-09-30 05:45:31 +00:00
|
|
|
}
|
2022-10-05 13:19:26 +00:00
|
|
|
|
2022-10-01 07:10:17 +00:00
|
|
|
if (tooltipHeight != null) {
|
|
|
|
const overflow = tooltipHeight - mouseY
|
|
|
|
if (overflow > 0) {
|
|
|
|
bottom -= overflow
|
|
|
|
}
|
2022-09-30 05:45:31 +00:00
|
|
|
}
|
2022-10-05 13:19:26 +00:00
|
|
|
|
2022-10-01 07:10:17 +00:00
|
|
|
return { left, bottom }
|
2022-09-30 05:45:31 +00:00
|
|
|
}
|
|
|
|
|
2022-10-04 21:02:44 +00:00
|
|
|
export type TooltipParams<T> = { x: number; y: number; data: T }
|
|
|
|
export type TooltipProps<X, T> = TooltipParams<T> & {
|
2022-09-30 23:16:04 +00:00
|
|
|
xScale: ContinuousScale<X>
|
|
|
|
}
|
2022-10-01 07:10:17 +00:00
|
|
|
|
2022-09-30 23:16:04 +00:00
|
|
|
export type TooltipComponent<X, T> = React.ComponentType<TooltipProps<X, T>>
|
2022-09-30 05:45:31 +00:00
|
|
|
export const TooltipContainer = (props: {
|
2022-10-01 07:10:17 +00:00
|
|
|
setElem: (e: HTMLElement | null) => void
|
2022-09-30 05:45:31 +00:00
|
|
|
pos: TooltipPosition
|
2022-10-04 08:18:22 +00:00
|
|
|
margin: Margin
|
2022-09-30 05:45:31 +00:00
|
|
|
className?: string
|
|
|
|
children: React.ReactNode
|
|
|
|
}) => {
|
2022-10-04 08:18:22 +00:00
|
|
|
const { setElem, pos, margin, className, children } = props
|
2022-09-28 03:24:42 +00:00
|
|
|
return (
|
|
|
|
<div
|
2022-10-01 07:10:17 +00:00
|
|
|
ref={setElem}
|
2022-09-28 03:24:42 +00:00
|
|
|
className={clsx(
|
|
|
|
className,
|
2022-09-30 07:03:31 +00:00
|
|
|
'pointer-events-none absolute z-10 whitespace-pre rounded border border-gray-200 bg-white/80 p-2 px-4 py-2 text-xs sm:text-sm'
|
2022-09-28 03:24:42 +00:00
|
|
|
)}
|
2022-10-04 08:18:22 +00:00
|
|
|
style={{
|
|
|
|
margin: `${margin.top}px ${margin.right}px ${margin.bottom}px ${margin.left}px`,
|
|
|
|
...pos,
|
|
|
|
}}
|
2022-09-28 03:24:42 +00:00
|
|
|
>
|
|
|
|
{children}
|
|
|
|
</div>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2022-10-04 08:18:22 +00:00
|
|
|
export const computeColorStops = <P,>(
|
|
|
|
data: P[],
|
|
|
|
pc: (p: P) => string,
|
|
|
|
px: (p: P) => number
|
|
|
|
) => {
|
|
|
|
const segments: { x: number; color: string }[] = []
|
|
|
|
let currOffset = 0
|
|
|
|
let currColor = pc(data[0])
|
|
|
|
for (const p of data) {
|
|
|
|
const c = pc(p)
|
|
|
|
if (c !== currColor) {
|
|
|
|
segments.push({ x: currOffset, color: currColor })
|
|
|
|
currOffset = px(p)
|
|
|
|
currColor = c
|
|
|
|
}
|
|
|
|
}
|
|
|
|
segments.push({ x: currOffset, color: currColor })
|
|
|
|
|
|
|
|
const stops: { x: number; color: string }[] = []
|
|
|
|
stops.push({ x: segments[0].x, color: segments[0].color })
|
|
|
|
for (const s of segments.slice(1)) {
|
|
|
|
stops.push({ x: s.x, color: stops[stops.length - 1].color })
|
|
|
|
stops.push({ x: s.x, color: s.color })
|
|
|
|
}
|
|
|
|
return stops
|
|
|
|
}
|
|
|
|
|
2022-09-28 03:24:42 +00:00
|
|
|
export const getDateRange = (contract: Contract) => {
|
|
|
|
const { createdTime, closeTime, resolutionTime } = contract
|
|
|
|
const isClosed = !!closeTime && Date.now() > closeTime
|
|
|
|
const endDate = resolutionTime ?? (isClosed ? closeTime : null)
|
2022-09-30 04:35:20 +00:00
|
|
|
return [createdTime, endDate ?? null] as const
|
2022-09-28 03:24:42 +00:00
|
|
|
}
|
2022-09-28 04:18:22 +00:00
|
|
|
|
|
|
|
export const getRightmostVisibleDate = (
|
2022-09-30 04:35:20 +00:00
|
|
|
contractEnd: number | null | undefined,
|
|
|
|
lastActivity: number | null | undefined,
|
|
|
|
now: number
|
2022-09-28 04:18:22 +00:00
|
|
|
) => {
|
|
|
|
if (contractEnd != null) {
|
|
|
|
return contractEnd
|
|
|
|
} else if (lastActivity != null) {
|
|
|
|
// client-DB clock divergence may cause last activity to be later than now
|
2022-09-30 04:35:20 +00:00
|
|
|
return Math.max(lastActivity, now)
|
2022-09-28 04:18:22 +00:00
|
|
|
} else {
|
|
|
|
return now
|
|
|
|
}
|
|
|
|
}
|
2022-09-29 04:14:34 +00:00
|
|
|
|
|
|
|
export const formatPct = (n: number, digits?: number) => {
|
|
|
|
return `${(n * 100).toFixed(digits ?? 0)}%`
|
|
|
|
}
|
|
|
|
|
|
|
|
export const formatDate = (
|
|
|
|
date: Date,
|
|
|
|
opts: { includeYear: boolean; includeHour: boolean; includeMinute: boolean }
|
|
|
|
) => {
|
|
|
|
const { includeYear, includeHour, includeMinute } = opts
|
|
|
|
const d = dayjs(date)
|
|
|
|
const now = Date.now()
|
|
|
|
if (
|
|
|
|
d.add(1, 'minute').isAfter(now) &&
|
|
|
|
d.subtract(1, 'minute').isBefore(now)
|
|
|
|
) {
|
|
|
|
return 'Now'
|
|
|
|
} else {
|
|
|
|
const dayName = d.isSame(now, 'day')
|
|
|
|
? 'Today'
|
|
|
|
: d.add(1, 'day').isSame(now, 'day')
|
|
|
|
? 'Yesterday'
|
|
|
|
: null
|
|
|
|
let format = dayName ? `[${dayName}]` : 'MMM D'
|
|
|
|
if (includeMinute) {
|
|
|
|
format += ', h:mma'
|
|
|
|
} else if (includeHour) {
|
|
|
|
format += ', ha'
|
|
|
|
} else if (includeYear) {
|
|
|
|
format += ', YYYY'
|
|
|
|
}
|
|
|
|
return d.format(format)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export const formatDateInRange = (d: Date, start: Date, end: Date) => {
|
|
|
|
const opts = {
|
|
|
|
includeYear: !dayjs(start).isSame(end, 'year'),
|
|
|
|
includeHour: dayjs(start).add(8, 'day').isAfter(end),
|
|
|
|
includeMinute: dayjs(end).diff(start, 'hours') < 2,
|
|
|
|
}
|
|
|
|
return formatDate(d, opts)
|
|
|
|
}
|