feat: add format breakdown to Reading Clock with improved tooltip

This commit is contained in:
neon
2026-03-26 10:41:07 -06:00
parent 48a1c6bb1d
commit 61746cb62c
18 changed files with 1540 additions and 93 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ function navigateToStaging() {
}
function navigateToStatistics() {
router.push({ name: 'statistics' })
router.push({ name: 'statistics', query: { tab: 'library' } })
}
function navigateToAccount() {
@@ -13,6 +13,8 @@ import type {
UserProgressFunnelComparison,
UserPeakHourStat,
UserReadingPacePoint,
UserReadingSessionTimeline,
UserReadingSessionTimelineItem,
UserSessionArchetypePoint,
UserStatisticsSummary,
FormatDistributionItem,
@@ -178,6 +180,31 @@ export async function fetchUserReadingPace(filters: StatisticsFilterConfig): Pro
return res.json() as Promise<UserReadingPacePoint[]>
}
export async function fetchUserReadingSessionTimeline(
filters: StatisticsFilterConfig,
year: number,
week: number,
): Promise<UserReadingSessionTimeline> {
const res = await api(`/api/v1/user-statistics/session-timeline${buildParams(filters, { year: String(year), week: String(week) })}`)
if (!res.ok) throw new Error(`User session timeline request failed: ${res.status}`)
return res.json() as Promise<UserReadingSessionTimeline>
}
export async function updateUserReadingSessionTimelineSession(
filters: StatisticsFilterConfig,
sessionId: number,
startedAt: string,
endedAt: string,
): Promise<UserReadingSessionTimelineItem> {
const res = await api(`/api/v1/user-statistics/session-timeline/${sessionId}${buildParams(filters)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ startedAt, endedAt }),
})
if (!res.ok) throw new Error(`User session timeline update failed: ${res.status}`)
return res.json() as Promise<UserReadingSessionTimelineItem>
}
export async function fetchGenreCooccurrence(filters: StatisticsFilterConfig): Promise<ChordDiagramData> {
const res = await api(`/api/v1/statistics/genre-cooccurrence${buildParams(filters)}`)
if (!res.ok) throw new Error(`Genre co-occurrence request failed: ${res.status}`)
@@ -34,6 +34,7 @@ const CHART_COMPONENTS: Record<StatisticsChartId, Component> = {
'reading-pace': defineAsyncComponent(() => import('./user/ReadingPaceScatterChart.vue')),
'books-completed': defineAsyncComponent(() => import('./user/BooksCompletedChart.vue')),
'reading-clock': defineAsyncComponent(() => import('./user/ReadingClockChart.vue')),
'reading-session-timeline': defineAsyncComponent(() => import('./user/ReadingSessionTimelineChart.vue')),
'session-archetypes': defineAsyncComponent(() => import('./user/SessionArchetypesChart.vue')),
}
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Check, ChevronDown, GripVertical, Settings2 } from 'lucide-vue-next'
import { VueDraggable } from 'vue-draggable-plus'
@@ -30,9 +31,14 @@ const {
setLibraryFilter,
} = useStatisticsConfig()
const route = useRoute()
const router = useRouter()
const { libraries, fetchLibraries } = useLibraries()
const configOpen = ref(false)
const activeTab = ref<'library' | 'user'>('library')
const initialTab = route.query.tab === 'user' ? 'user' : 'library'
const activeTab = ref<'library' | 'user'>(initialTab)
const loaded = ref<Set<'library' | 'user'>>(new Set([initialTab]))
// Called in setup (not onMounted) so filters are populated before chart children mount.
init()
@@ -93,6 +99,8 @@ function chartMeta(id: StatisticsChartId) {
function setTab(tab: 'library' | 'user') {
activeTab.value = tab
loaded.value.add(tab)
void router.replace({ query: { ...route.query, tab } })
}
</script>
@@ -224,10 +232,10 @@ function setTab(tab: 'library' | 'user') {
</SheetContent>
</Sheet>
<div v-show="activeTab === 'library'">
<div v-if="loaded.has('library')" v-show="activeTab === 'library'">
<StatisticsGrid :charts="visibleLibraryCharts" />
</div>
<div v-show="activeTab === 'user'">
<div v-if="loaded.has('user')" v-show="activeTab === 'user'">
<StatisticsGrid :charts="visibleUserCharts" />
</div>
</div>
@@ -23,7 +23,7 @@ watchEffect(() => {
option.value = {}
if (isEmpty.value || !hasEnoughData.value || !data.value.length) return
const palette = getThemePalette(themeStore.theme, themeStore.accent)
const palette = getThemePalette(themeStore.theme, themeStore.accent, 0.8, 0.8)
const background = readCssColor('--background')
option.value = {
@@ -42,6 +42,8 @@ watchEffect(() => {
roam: false,
nodeClick: false,
breadcrumb: { show: false },
top: 20,
bottom: 20,
data: data.value.map((item, i) => ({
name: item.genre,
value: item.readingSeconds,
@@ -3,38 +3,22 @@ import { computed, shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Clock } from 'lucide-vue-next'
import { useThemeStore } from '@/stores/theme'
import { getThemePalette } from '@/lib/echarts'
import { useUserPeakReadingHours } from '../../composables/useUserPeakReadingHours'
import ChartCard from '../ChartCard.vue'
// prettier-ignore
const HOUR_LABELS = [
'12am',
'1am',
'2am',
'3am',
'4am',
'5am',
'6am',
'7am',
'8am',
'9am',
'10am',
'11am',
'12pm',
'1pm',
'2pm',
'3pm',
'4pm',
'5pm',
'6pm',
'7pm',
'8pm',
'9pm',
'10pm',
'11pm',
'12a', '1a', '2a', '3a', '4a', '5a',
'6a', '7a', '8a', '9a', '10a', '11a',
'12p', '1p', '2p', '3p', '4p', '5p',
'6p', '7p', '8p', '9p', '10p', '11p',
]
const MIN_EVENTS = 20
const themeStore = useThemeStore()
const { data, loading, error } = useUserPeakReadingHours()
const option = shallowRef({})
@@ -51,14 +35,26 @@ watchEffect(() => {
option.value = {}
if (isEmpty.value || lowConfidence.value || !data.value.length) return
const values = data.value.map((d) => Math.round(d.readingSeconds / 60))
const palette = getThemePalette(themeStore.theme, themeStore.accent)
const formats = [...new Set(data.value.flatMap((d) => Object.keys(d.byFormat)))].sort()
const series = formats.map((fmt, i) => ({
type: 'bar',
name: fmt,
data: data.value.map((d) => Math.round((d.byFormat[fmt] ?? 0) / 60)),
coordinateSystem: 'polar',
stack: 'clock',
itemStyle: { color: palette[i % palette.length] },
emphasis: { focus: 'series' },
}))
option.value = {
polar: { radius: ['20%', '80%'] },
angleAxis: {
type: 'category',
data: HOUR_LABELS,
startAngle: 90,
startAngle: 82.5,
clockwise: false,
axisLabel: { fontSize: 10 },
axisTick: { show: false },
@@ -71,23 +67,42 @@ watchEffect(() => {
splitLine: { show: false },
},
tooltip: {
trigger: 'item',
formatter: (params: { dataIndex: number; data: number }) => {
const label = HOUR_LABELS[params.dataIndex]
const mins = params.data
const events = data.value[params.dataIndex]?.eventsCount ?? 0
trigger: 'axis',
appendToBody: true,
axisPointer: { type: 'none' },
formatter: (params: { dataIndex: number; seriesName: string; data: number; color: string }[]) => {
if (!params.length) return ''
const idx = params[0]!.dataIndex
const from = HOUR_LABELS[idx]
const to = HOUR_LABELS[(idx + 1) % 24]
const total = data.value[idx]?.readingSeconds ?? 0
const events = data.value[idx]?.eventsCount ?? 0
const eventLabel = events === 1 ? 'session' : 'sessions'
return `<strong>${label}</strong><br/>${mins} min<br/>${events} ${eventLabel}`
const fmtTime = (mins: number) => (mins >= 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`)
const totalMins = Math.round(total / 60)
const active = params.filter((p) => p.data > 0)
const formatRows =
active.length > 1
? `<div style="border-top:1px solid rgba(128,128,128,0.2);margin-top:5px;padding-top:5px">${active
.map(
(p) =>
`<div style="display:flex;align-items:center;gap:6px;margin-top:2px">` +
`<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${p.color};flex-shrink:0"></span>` +
`<span style="color:rgba(180,180,180,0.9)">${p.seriesName}</span>` +
`<span style="margin-left:auto;padding-left:12px">${fmtTime(p.data)}</span>` +
`</div>`,
)
.join('')}</div>`
: ''
return (
`<div style="font-size:12px;line-height:1.5">` +
`<div><span style="font-weight:600">${from} – ${to}</span> <span style="color:rgba(180,180,180,0.9);margin-left:4px">${fmtTime(totalMins)} · ${events} ${eventLabel}</span></div>` +
formatRows +
`</div>`
)
},
},
series: [
{
type: 'bar',
data: values,
coordinateSystem: 'polar',
itemStyle: { borderRadius: [3, 3, 0, 0] },
},
],
series,
}
})
</script>
@@ -22,9 +22,6 @@ const heatmapPaletteState = computed(() => ({
accent: themeStore.accent,
palette: buildHeatmapPalette({ theme: themeStore.theme, profile: 'github' }),
}))
const legendSwatches = computed(() => {
return heatmapPaletteState.value.palette.scale
})
function buildContributionPieces(scale: string[]) {
// Fixed bins (minutes/day) for stronger, stable contrast across datasets.
@@ -32,11 +29,11 @@ function buildContributionPieces(scale: string[]) {
const b2 = 30
const b3 = 60
return [
{ value: 0, color: scale[0] },
{ gt: 0, lte: b1, color: scale[1] },
{ gt: b1, lte: b2, color: scale[2] },
{ gt: b2, lte: b3, color: scale[3] },
{ gt: b3, color: scale[4] },
{ value: 0, label: '0m', color: scale[0] },
{ gt: 0, lte: b1, label: `1-${b1}m`, color: scale[1] },
{ gt: b1, lte: b2, label: `${b1 + 1}-${b2}m`, color: scale[2] },
{ gt: b2, lte: b3, label: `${b2 + 1}-${b3}m`, color: scale[3] },
{ gt: b3, label: `${b3}m+`, color: scale[4] },
]
}
@@ -78,17 +75,27 @@ watchEffect(() => {
},
visualMap: {
type: 'piecewise',
show: false,
show: true,
calculable: false,
dimension: 1,
pieces,
orient: 'horizontal',
left: 'center',
top: 10,
itemWidth: 10,
itemHeight: 10,
itemGap: 8,
textStyle: {
color: palette.axisColor,
fontSize: 12,
},
},
calendar: {
top: 40,
left: 32,
right: 12,
top: 100,
left: 25,
right: 0,
bottom: 20,
cellSize: ['auto', 12],
cellSize: ['auto', 13],
range: [yearStartKey, yearEndKey],
yearLabel: { show: false },
splitLine: { show: false },
@@ -101,15 +108,15 @@ watchEffect(() => {
},
dayLabel: {
show: true,
firstDay: 1,
firstDay: 0,
fontSize: 10,
color: palette.axisColor,
margin: 10,
margin: 8,
nameMap: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'],
},
itemStyle: {
color: 'transparent',
borderWidth: 0.8,
borderWidth: 0.5,
borderColor: palette.borderColor,
borderRadius: 1,
},
@@ -131,22 +138,8 @@ watchEffect(() => {
<div v-if="lowConfidence" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Need activity on at least {{ MIN_ACTIVE_DAYS }} days to show a reliable pattern
</div>
<div v-else class="flex h-full min-h-0 flex-col">
<div class="min-h-0 flex-1 rounded-md px-2 py-3">
<VChart :option autoresize class="h-full w-full" />
</div>
<div class="text-muted-foreground mt-2 flex items-center justify-center gap-2 text-xs">
<span>Less</span>
<div class="flex items-center gap-1" aria-hidden="true">
<span
v-for="(swatch, idx) in legendSwatches"
:key="idx"
class="border-border/60 inline-block size-2.5 rounded-[3px] border"
:style="{ backgroundColor: swatch }"
/>
</div>
<span>More</span>
</div>
<div v-else class="h-full min-h-0 rounded-md px-2 py-2">
<VChart :option autoresize class="h-full w-full" />
</div>
</ChartCard>
</template>
@@ -0,0 +1,743 @@
<script setup lang="ts">
import { computed, ref, shallowRef, watch } from 'vue'
import VChart from 'vue-echarts'
import { CalendarRange } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import type { UserReadingSessionTimeline, UserReadingSessionTimelineItem } from '@projectx/types'
import { getFormatColor } from '@/features/book/lib/format-colors'
import { fetchUserReadingSessionTimeline, updateUserReadingSessionTimelineSession } from '../../api/statistics.api'
import { useStatisticsConfig } from '../../composables/useStatisticsConfig'
import ChartCard from '../ChartCard.vue'
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as const
const MAX_TRACKS = 3
const MINUTES_PER_DAY = 24 * 60
const SNAP_MINUTES = 5
interface SessionSegment {
sessionId: number
bookId: number
bookTitle: string | null
bookFormat: string | null
dayIndex: number
startMinute: number
endMinute: number
durationMinutes: number
track: number
totalTracks: number
}
interface DragState {
sessionId: number
durationMs: number
offsetMinutes: number
}
interface DragPreview {
sessionId: number
dayIndex: number
startMinute: number
endMinute: number
durationMinutes: number
startedAt: string
endedAt: string
hasConflict: boolean
}
interface PointerOffset {
x: number
y: number
}
const { filters } = useStatisticsConfig()
const now = new Date()
const selectedYear = ref(getUtcIsoWeekYear(now))
const selectedWeek = ref(getUtcIsoWeek(now))
const loading = ref(true)
const error = ref(false)
const persistLoading = ref(false)
const dragEnabled = ref(false)
const dragState = ref<DragState | null>(null)
const dragPreview = ref<DragPreview | null>(null)
const chartRef = ref<InstanceType<typeof VChart> | null>(null)
const option = shallowRef({})
const timeline = ref<UserReadingSessionTimeline>({
year: selectedYear.value,
week: selectedWeek.value,
weekStart: formatYmd(getUtcIsoWeekStart(selectedYear.value, selectedWeek.value)),
weekEnd: formatYmd(addUtcDays(getUtcIsoWeekStart(selectedYear.value, selectedWeek.value), 6)),
items: [],
})
const isEmpty = computed(() => timeline.value.items.length === 0)
const weekStartDate = computed(() => parseYmdUtc(timeline.value.weekStart) ?? getUtcIsoWeekStart(selectedYear.value, selectedWeek.value))
const weekEndExclusive = computed(() => addUtcDays(weekStartDate.value, 7))
const weekRangeLabel = computed(() => `${formatDateLabel(timeline.value.weekStart)} - ${formatDateLabel(timeline.value.weekEnd)}`)
const weekOptions = computed(() => {
const count = getUtcIsoWeeksInYear(selectedYear.value)
return Array.from({ length: count }, (_, i) => i + 1)
})
const yearOptions = computed(() => {
const currentIsoYear = getUtcIsoWeekYear(new Date())
const years = Array.from({ length: 11 }, (_, i) => currentIsoYear - i)
if (!years.includes(selectedYear.value)) {
years.push(selectedYear.value)
years.sort((a, b) => b - a)
}
return years
})
const segments = computed<SessionSegment[]>(() => buildSegments(timeline.value.items, weekStartDate.value, weekEndExclusive.value))
const sessionsById = computed(() => {
const map = new Map<number, UserReadingSessionTimelineItem>()
for (const item of timeline.value.items) {
map.set(item.sessionId, item)
}
return map
})
watch(selectedYear, (year) => {
const maxWeek = getUtcIsoWeeksInYear(year)
if (selectedWeek.value > maxWeek) selectedWeek.value = maxWeek
})
let requestId = 0
watch(
[() => filters.value.libraryIds.join(','), selectedYear, selectedWeek],
() => {
void loadTimeline()
},
{ immediate: true },
)
watch(dragEnabled, (enabled) => {
if (!enabled) clearDragState()
})
watch(
[segments, dragPreview, dragEnabled],
() => {
option.value = makeOption(segments.value, dragPreview.value)
},
{ immediate: true },
)
async function loadTimeline() {
const id = ++requestId
loading.value = true
error.value = false
clearDragState()
try {
const next = await fetchUserReadingSessionTimeline(filters.value, selectedYear.value, selectedWeek.value)
if (id !== requestId) return
timeline.value = next
selectedYear.value = next.year
selectedWeek.value = next.week
} catch {
if (id !== requestId) return
error.value = true
} finally {
if (id === requestId) loading.value = false
}
}
function makeOption(data: SessionSegment[], preview: DragPreview | null) {
const seriesData = data.map((segment) => ({
value: [segment.dayIndex, segment.startMinute, segment.endMinute, segment.track, segment.totalTracks, segment.durationMinutes],
sessionId: segment.sessionId,
bookId: segment.bookId,
bookTitle: segment.bookTitle,
bookFormat: segment.bookFormat,
durationMinutes: segment.durationMinutes,
itemStyle: {
color: getFormatColor(segment.bookFormat),
opacity: 0.9,
},
}))
const previewData = preview
? [
{
value: [preview.dayIndex, preview.startMinute, preview.endMinute, 0, 1, preview.durationMinutes],
sessionId: preview.sessionId,
itemStyle: {
color: preview.hasConflict ? 'rgba(244, 63, 94, 0.25)' : 'rgba(16, 185, 129, 0.25)',
borderColor: preview.hasConflict ? 'rgba(244, 63, 94, 0.9)' : 'rgba(16, 185, 129, 0.95)',
borderWidth: 2,
borderType: 'dashed',
},
},
]
: []
return {
grid: { top: 16, right: 12, bottom: 26, left: 52, containLabel: false },
xAxis: {
type: 'value',
min: 0,
max: MINUTES_PER_DAY,
interval: 60,
splitLine: { lineStyle: { type: 'dashed', opacity: 0.4 } },
axisLabel: {
fontSize: 11,
formatter: (value: number) => formatHourLabel(Number(value)),
},
},
yAxis: {
type: 'category',
data: DAY_LABELS,
axisTick: { show: false },
splitLine: { show: true, lineStyle: { opacity: 0.25 } },
axisLabel: { fontSize: 11 },
},
tooltip: {
trigger: 'item',
formatter: (params: {
data: { bookId?: number; bookTitle?: string | null; bookFormat?: string | null; durationMinutes?: number; value: number[] }
}) => {
const d = params.data
if (!d || !d.value) return ''
const start = formatTimeLabel(d.value[1] ?? 0)
const end = formatTimeLabel(d.value[2] ?? 0)
const duration = formatDuration(Math.max(0, d.durationMinutes ?? 0))
const title = escapeHtml(d.bookTitle ?? 'Reading session')
const format = escapeHtml(d.bookFormat ?? 'Unknown')
const cover = d.bookId
? `<img src="/api/v1/books/${d.bookId}/thumbnail" alt="Cover" style="width:32px;height:48px;object-fit:cover;border-radius:4px;" />`
: ''
return [
'<div style="display:flex;gap:8px;align-items:flex-start;max-width:320px;">',
cover,
'<div>',
`<div style="font-weight:600;margin-bottom:2px;">${title}</div>`,
`<div style="opacity:0.85;">${start} - ${end}</div>`,
`<div style="opacity:0.85;">${duration}</div>`,
`<div style="opacity:0.72;">${format}</div>`,
'</div>',
'</div>',
].join('')
},
},
series: [
{
id: 'sessionTimeline',
type: 'custom',
renderItem: renderTimelineItem,
encode: {
x: [1, 2],
y: 0,
tooltip: [1, 2],
},
data: seriesData,
silent: false,
},
{
id: 'sessionTimelineDragPreview',
type: 'custom',
renderItem: renderTimelineItem,
encode: {
x: [1, 2],
y: 0,
},
data: previewData,
silent: true,
},
],
}
}
function renderTimelineItem(
params: { coordSys: { x: number; width: number } },
api: {
value: (dim: number) => number
coord: (data: number[]) => [number, number]
size: (data: number[]) => [number, number]
style: (extra?: Record<string, unknown>) => Record<string, unknown>
},
) {
const dayIndex = Number(api.value(0))
const start = Number(api.value(1))
const end = Number(api.value(2))
const track = Number(api.value(3))
const totalTracks = Math.max(1, Number(api.value(4)))
const durationMinutes = Number(api.value(5))
const startCoord = api.coord([start, dayIndex])
const endCoord = api.coord([end, dayIndex])
const rowHeight = Math.abs(api.size([0, 1])[1])
const trackGap = 2
const trackHeight = totalTracks > 1 ? Math.max(4, (rowHeight - (totalTracks - 1) * trackGap) / totalTracks) : Math.max(10, rowHeight * 0.72)
const baseline = startCoord[1] - rowHeight / 2
const x = Math.min(startCoord[0], endCoord[0])
const width = Math.max(2, Math.abs(endCoord[0] - startCoord[0]))
const y = totalTracks > 1 ? baseline + track * (trackHeight + trackGap) : startCoord[1] - trackHeight / 2
const left = params.coordSys.x
const right = params.coordSys.x + params.coordSys.width
const clippedX = Math.max(left, x)
const clippedW = Math.min(right, x + width) - clippedX
if (clippedW <= 0) return null
const shortLabel = durationMinutes >= 60 && clippedW > 56 ? formatTimeLabel(start) : ''
return {
type: 'group',
children: [
{
type: 'rect',
shape: {
x: clippedX,
y,
width: clippedW,
height: trackHeight,
r: 4,
},
style: api.style({
stroke: 'rgba(255,255,255,0.2)',
lineWidth: 1,
}),
},
{
type: 'text',
silent: true,
style: {
x: clippedX + 4,
y: y + trackHeight / 2,
text: shortLabel,
textVerticalAlign: 'middle',
textFill: 'rgba(255,255,255,0.9)',
fontSize: 10,
},
},
],
}
}
function shiftWeek(delta: number) {
const base = getUtcIsoWeekStart(selectedYear.value, selectedWeek.value)
base.setUTCDate(base.getUTCDate() + delta * 7)
selectedYear.value = getUtcIsoWeekYear(base)
selectedWeek.value = getUtcIsoWeek(base)
}
function handleChartMouseDown(params: { seriesId?: string; dataIndex?: number; event?: unknown } | null) {
if (!dragEnabled.value || persistLoading.value) return
if (!params || params.seriesId !== 'sessionTimeline') return
const dataIndex = Number(params.dataIndex)
if (!Number.isFinite(dataIndex) || dataIndex < 0 || dataIndex >= segments.value.length) return
const pointer = extractPointerOffset(params.event)
const chart = chartRef.value
if (!pointer || !chart) return
const dataCoord = chart.convertFromPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [pointer.x, pointer.y]) as [number, number] | null
if (!dataCoord) return
const segment = segments.value[dataIndex]!
const source = sessionsById.value.get(segment.sessionId)
if (!source) return
const sourceStart = new Date(source.startedAt)
const sourceEnd = new Date(source.endedAt)
const durationMs = sourceEnd.getTime() - sourceStart.getTime()
if (!Number.isFinite(durationMs) || durationMs <= 0) return
dragState.value = {
sessionId: segment.sessionId,
durationMs,
offsetMinutes: dataCoord[0] - segment.startMinute,
}
}
function handleZrMouseMove(event: { offsetX: number; offsetY: number }) {
const state = dragState.value
const chart = chartRef.value
if (!state || !chart) return
const point = chart.convertFromPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [event.offsetX, event.offsetY]) as [number, number] | null
if (!point) return
const rawMinute = point[0] - state.offsetMinutes
const rawDay = Math.round(point[1])
const dayIndex = clamp(rawDay, 0, 6)
const snappedStartMinute = clamp(Math.round(rawMinute / SNAP_MINUTES) * SNAP_MINUTES, 0, MINUTES_PER_DAY - 1)
const weekStartMs = weekStartDate.value.getTime()
const weekEndMs = weekEndExclusive.value.getTime()
const unclampedStartMs = weekStartMs + dayIndex * 86_400_000 + snappedStartMinute * 60_000
const minStartMs = weekStartMs
const maxStartMs = Math.max(minStartMs, weekEndMs - state.durationMs)
const startMs = clamp(unclampedStartMs, minStartMs, maxStartMs)
const endMs = startMs + state.durationMs
const dayOffset = Math.floor((startMs - weekStartMs) / 86_400_000)
const clampedDayIndex = clamp(dayOffset, 0, 6)
const startOfDayMs = weekStartMs + clampedDayIndex * 86_400_000
const minuteInDay = (startMs - startOfDayMs) / 60_000
const endMinuteInDay = minuteInDay + state.durationMs / 60_000
const startDate = new Date(startMs)
const endDate = new Date(endMs)
dragPreview.value = {
sessionId: state.sessionId,
dayIndex: clampedDayIndex,
startMinute: minuteInDay,
endMinute: endMinuteInDay,
durationMinutes: state.durationMs / 60_000,
startedAt: startDate.toISOString(),
endedAt: endDate.toISOString(),
hasConflict: hasSessionConflict(state.sessionId, startDate, endDate),
}
}
function handleZrMouseUp() {
void commitDrag()
}
function handleZrGlobalOut() {
clearDragState()
}
async function commitDrag() {
const state = dragState.value
const preview = dragPreview.value
clearDragState()
if (!state || !preview) return
if (preview.hasConflict) {
toast.error('Session overlaps another session in this window')
return
}
persistLoading.value = true
try {
const updated = await updateUserReadingSessionTimelineSession(filters.value, state.sessionId, preview.startedAt, preview.endedAt)
timeline.value = {
...timeline.value,
items: timeline.value.items.map((item) => (item.sessionId === updated.sessionId ? updated : item)),
}
} catch {
toast.error('Failed to move session')
await loadTimeline()
} finally {
persistLoading.value = false
}
}
function clearDragState() {
dragState.value = null
dragPreview.value = null
}
function hasSessionConflict(sessionId: number, start: Date, end: Date): boolean {
const startMs = start.getTime()
const endMs = end.getTime()
return timeline.value.items.some((item) => {
if (item.sessionId === sessionId) return false
const otherStart = new Date(item.startedAt).getTime()
const otherEnd = new Date(item.endedAt).getTime()
return startMs < otherEnd && endMs > otherStart
})
}
function buildSegments(items: UserReadingSessionTimelineItem[], weekStart: Date, weekEnd: Date): SessionSegment[] {
const byDay = new Map<number, SessionSegment[]>()
for (const item of items) {
const start = new Date(item.startedAt)
const end = new Date(item.endedAt)
if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) continue
const clippedStart = start.getTime() < weekStart.getTime() ? new Date(weekStart) : start
const clippedEnd = end.getTime() > weekEnd.getTime() ? new Date(weekEnd) : end
if (clippedEnd <= clippedStart) continue
let cursor = new Date(clippedStart)
while (cursor < clippedEnd) {
const dayStart = startOfUtcDay(cursor)
const nextDay = addUtcDays(dayStart, 1)
const segmentEnd = clippedEnd < nextDay ? clippedEnd : nextDay
const dayIndex = (cursor.getUTCDay() + 6) % 7
const startMinute = cursor.getUTCHours() * 60 + cursor.getUTCMinutes() + cursor.getUTCSeconds() / 60
const endMinute =
segmentEnd.getTime() === nextDay.getTime()
? MINUTES_PER_DAY
: segmentEnd.getUTCHours() * 60 + segmentEnd.getUTCMinutes() + segmentEnd.getUTCSeconds() / 60
if (!byDay.has(dayIndex)) byDay.set(dayIndex, [])
byDay.get(dayIndex)!.push({
sessionId: item.sessionId,
bookId: item.bookId,
bookTitle: item.bookTitle,
bookFormat: item.bookFormat,
dayIndex,
startMinute,
endMinute: Math.max(startMinute + 0.5, endMinute),
durationMinutes: Math.max((segmentEnd.getTime() - cursor.getTime()) / 60_000, 0.5),
track: 0,
totalTracks: 1,
})
cursor = new Date(segmentEnd)
}
}
const output: SessionSegment[] = []
for (let dayIndex = 0; dayIndex < 7; dayIndex++) {
const daySegments = byDay.get(dayIndex) ?? []
daySegments.sort((a, b) => (a.startMinute === b.startMinute ? b.endMinute - a.endMinute : a.startMinute - b.startMinute))
const tracks: number[] = []
for (const segment of daySegments) {
let trackIndex = -1
for (let i = 0; i < tracks.length; i++) {
if (segment.startMinute >= tracks[i]!) {
trackIndex = i
break
}
}
if (trackIndex === -1) {
if (tracks.length < MAX_TRACKS) {
tracks.push(segment.endMinute)
trackIndex = tracks.length - 1
} else {
trackIndex = MAX_TRACKS - 1
tracks[trackIndex] = Math.max(tracks[trackIndex] ?? 0, segment.endMinute)
}
} else {
tracks[trackIndex] = segment.endMinute
}
output.push({
...segment,
track: trackIndex,
totalTracks: Math.max(1, Math.min(MAX_TRACKS, tracks.length)),
})
}
}
return output
}
function extractPointerOffset(eventLike: unknown): PointerOffset | null {
if (typeof eventLike !== 'object' || !eventLike) return null
const maybe = eventLike as { offsetX?: number; offsetY?: number; event?: { offsetX?: number; offsetY?: number } }
if (typeof maybe.offsetX === 'number' && typeof maybe.offsetY === 'number') {
return { x: maybe.offsetX, y: maybe.offsetY }
}
if (typeof maybe.event?.offsetX === 'number' && typeof maybe.event?.offsetY === 'number') {
return { x: maybe.event.offsetX, y: maybe.event.offsetY }
}
return null
}
function formatDateLabel(ymd: string): string {
const parts = ymd.split('-').map((p) => Number(p))
if (parts.length !== 3 || parts.some((n) => !Number.isFinite(n))) return ymd
const [year, month, day] = parts
const date = new Date(year!, (month ?? 1) - 1, day ?? 1)
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
function formatHourLabel(minutes: number): string {
const hour24 = Math.floor(minutes / 60)
if (!Number.isFinite(hour24)) return ''
const hour = ((hour24 % 24) + 24) % 24
if (hour === 0) return '12a'
if (hour < 12) return `${hour}a`
if (hour === 12) return '12p'
return `${hour - 12}p`
}
function formatTimeLabel(minutes: number): string {
const normalized = clamp(minutes, 0, MINUTES_PER_DAY)
const clockMinutes = normalized === MINUTES_PER_DAY ? 0 : normalized
const hour24 = Math.floor(clockMinutes / 60)
const minute = Math.floor(clockMinutes % 60)
const hour12 = hour24 % 12 === 0 ? 12 : hour24 % 12
const period = hour24 < 12 ? 'AM' : 'PM'
return `${hour12}:${String(minute).padStart(2, '0')} ${period}`
}
function formatDuration(minutes: number): string {
const totalSeconds = Math.round(minutes * 60)
const hours = Math.floor(totalSeconds / 3600)
const mins = Math.floor((totalSeconds % 3600) / 60)
const secs = totalSeconds % 60
if (hours > 0) return `${hours}h ${mins}m ${secs}s`
if (mins > 0) return `${mins}m ${secs}s`
return `${secs}s`
}
function escapeHtml(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}
function parseYmdUtc(value: string): Date | null {
const [yearStr, monthStr, dayStr] = value.split('-')
const year = Number(yearStr)
const month = Number(monthStr)
const day = Number(dayStr)
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null
return new Date(Date.UTC(year, month - 1, day))
}
function formatYmd(date: Date): string {
const y = date.getUTCFullYear()
const m = String(date.getUTCMonth() + 1).padStart(2, '0')
const d = String(date.getUTCDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
function startOfUtcDay(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
}
function addUtcDays(date: Date, days: number): Date {
const next = new Date(date)
next.setUTCDate(next.getUTCDate() + days)
return next
}
function startOfUtcIsoWeek(date: Date): Date {
const start = startOfUtcDay(date)
const day = (start.getUTCDay() + 6) % 7
start.setUTCDate(start.getUTCDate() - day)
return start
}
function getUtcIsoWeekYear(date: Date): number {
const d = startOfUtcDay(date)
const day = (d.getUTCDay() + 6) % 7
d.setUTCDate(d.getUTCDate() - day + 3)
return d.getUTCFullYear()
}
function getUtcIsoWeek(date: Date): number {
const d = startOfUtcDay(date)
const day = (d.getUTCDay() + 6) % 7
d.setUTCDate(d.getUTCDate() - day + 3)
const firstThursday = new Date(Date.UTC(d.getUTCFullYear(), 0, 4))
const firstDay = (firstThursday.getUTCDay() + 6) % 7
firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDay + 3)
return 1 + Math.round((d.getTime() - firstThursday.getTime()) / 604_800_000)
}
function getUtcIsoWeeksInYear(year: number): number {
return getUtcIsoWeek(new Date(Date.UTC(year, 11, 28)))
}
function getUtcIsoWeekStart(year: number, week: number): Date {
const jan4 = new Date(Date.UTC(year, 0, 4))
const week1Start = startOfUtcIsoWeek(jan4)
const start = new Date(week1Start)
start.setUTCDate(start.getUTCDate() + (week - 1) * 7)
return start
}
</script>
<template>
<ChartCard title="Reading Session Timeline" :icon="CalendarRange" :color-index="11" :loading="loading" :error="error" :empty="false">
<template #controls>
<button
type="button"
:disabled="persistLoading"
:class="[
'border-border rounded-md border px-2 py-1 text-xs transition-colors',
dragEnabled ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground',
persistLoading && 'pointer-events-none opacity-50',
]"
@click="dragEnabled = !dragEnabled"
>
{{ dragEnabled ? 'Drag: On' : 'Drag: Off' }}
</button>
</template>
<div class="flex h-full min-h-0 flex-col gap-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-1.5">
<button
type="button"
class="border-border text-muted-foreground hover:text-foreground rounded-md border px-2 py-1 text-xs"
:disabled="persistLoading"
@click="shiftWeek(-1)"
>
Prev
</button>
<button
type="button"
class="border-border text-muted-foreground hover:text-foreground rounded-md border px-2 py-1 text-xs"
:disabled="persistLoading"
@click="shiftWeek(1)"
>
Next
</button>
<select
v-model.number="selectedYear"
class="border-border bg-background text-foreground rounded-md border px-2 py-1 text-xs"
:disabled="persistLoading"
>
<option v-for="year in yearOptions" :key="year" :value="year">{{ year }}</option>
</select>
<select
v-model.number="selectedWeek"
class="border-border bg-background text-foreground rounded-md border px-2 py-1 text-xs"
:disabled="persistLoading"
>
<option v-for="week in weekOptions" :key="week" :value="week">Week {{ week }}</option>
</select>
</div>
<p class="text-muted-foreground text-xs">{{ weekRangeLabel }}</p>
</div>
<p v-if="dragEnabled" class="text-muted-foreground border-border/60 bg-muted/20 rounded-md border px-2 py-1 text-xs">
Drag bars to move sessions. Duration is locked and overlapping sessions are rejected.
</p>
<div class="border-border/60 bg-muted/5 min-h-0 flex-1 rounded-lg border p-2">
<div v-if="isEmpty" class="text-muted-foreground flex h-full flex-col items-center justify-center gap-1 text-center">
<p class="text-sm font-medium">No data yet</p>
<p class="text-xs opacity-70">No sessions for this week. Use Prev/Next or week selector.</p>
</div>
<VChart
v-else
ref="chartRef"
:option="option"
autoresize
class="h-full w-full"
@mousedown="handleChartMouseDown"
@zr:mousemove="handleZrMouseMove"
@zr:mouseup="handleZrMouseUp"
@zr:globalout="handleZrGlobalOut"
/>
</div>
</div>
</ChartCard>
</template>
@@ -186,6 +186,12 @@ export const STATISTICS_CHART_META: Record<StatisticsChartId, StatisticsChartMet
size: '1x1',
category: 'user',
},
'reading-session-timeline': {
label: 'Reading Session Timeline',
icon: Clock3,
size: '2x1',
category: 'user',
},
'session-archetypes': {
label: 'Session Archetypes',
icon: Layers,
+5 -3
View File
@@ -4,6 +4,7 @@ import {
BarChart,
BoxplotChart,
ChordChart,
CustomChart,
FunnelChart,
HeatmapChart,
LineChart,
@@ -32,6 +33,7 @@ use([
PieChart,
BarChart,
ChordChart,
CustomChart,
LineChart,
FunnelChart,
HeatmapChart,
@@ -171,12 +173,12 @@ function oklchToHex(L: number, C: number, H: number): string {
)
}
export function getThemePalette(mode: ProjectxThemeMode, accent: string): string[] {
export function getThemePalette(mode: ProjectxThemeMode, accent: string, chromaScale = 1, lightnessScale = 1): string[] {
const resolvedAccent = accent in ACCENT_PRIMARY ? accent : DEFAULT_ACCENT
const def = ACCENT_PRIMARY[resolvedAccent]!
const dark = mode === 'dark'
const L = dark ? def[2] : def[0]
const C = dark ? def[3] : def[1]
const L = (dark ? def[2] : def[0]) * lightnessScale
const C = (dark ? def[3] : def[1]) * chromaScale
const H = ACCENT_HUE[resolvedAccent]!
return HUE_OFFSETS.map((off) => oklchToHex(L, C, H + off))
}
+2
View File
@@ -23,6 +23,7 @@ export type StatisticsChartId =
| "reading-pace"
| "books-completed"
| "reading-clock"
| "reading-session-timeline"
| "session-archetypes"
| "genre-cooccurrence";
@@ -77,6 +78,7 @@ export const DEFAULT_USER_CHART_ORDER: StatisticsChartId[] = [
"reading-pace",
"books-completed",
"reading-clock",
"reading-session-timeline",
"session-archetypes",
];
+19
View File
@@ -17,6 +17,7 @@ export interface UserPeakHourStat {
hour: number;
readingSeconds: number;
eventsCount: number;
byFormat: Record<string, number>;
}
export interface UserFavoriteDayStat {
@@ -77,6 +78,24 @@ export interface UserGenreReadingTimeItem {
readingSeconds: number;
}
export interface UserReadingSessionTimelineItem {
sessionId: number;
bookId: number;
bookTitle: string | null;
bookFormat: string | null;
startedAt: string;
endedAt: string;
durationSeconds: number;
}
export interface UserReadingSessionTimeline {
year: number;
week: number;
weekStart: string;
weekEnd: string;
items: UserReadingSessionTimelineItem[];
}
export interface UserReadingPacePoint {
durationSeconds: number;
progressDelta: number;
@@ -0,0 +1,9 @@
import { IsDateString } from 'class-validator';
export class UpdateUserSessionTimelineSessionDto {
@IsDateString()
startedAt!: string;
@IsDateString()
endedAt!: string;
}
@@ -0,0 +1,20 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { UserStatisticsFilterQueryDto } from './user-statistics-filter-query.dto';
export class UserSessionTimelineQueryDto extends UserStatisticsFilterQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1970)
@Max(2100)
year?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(53)
week?: number;
}
@@ -1,9 +1,11 @@
import { Controller, Get, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, ParseIntPipe, Patch, Query } from '@nestjs/common';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { UserDailyReadingQueryDto } from './dto/user-daily-reading-query.dto';
import { UserGoalTrajectoryQueryDto } from './dto/user-goal-trajectory-query.dto';
import { UserSessionTimelineQueryDto } from './dto/user-session-timeline-query.dto';
import { UpdateUserSessionTimelineSessionDto } from './dto/update-user-session-timeline-session.dto';
import { UserStatisticsFilterQueryDto } from './dto/user-statistics-filter-query.dto';
import { UserStatisticsService } from './user-statistics.service';
@@ -36,6 +38,21 @@ export class UserStatisticsController {
return this.userStatisticsService.getFavoriteReadingDays(user, query);
}
@Get('session-timeline')
getSessionTimeline(@CurrentUser() user: RequestUser, @Query() query: UserSessionTimelineQueryDto) {
return this.userStatisticsService.getSessionTimeline(user, query);
}
@Patch('session-timeline/:sessionId')
updateSessionTimelineSession(
@CurrentUser() user: RequestUser,
@Param('sessionId', ParseIntPipe) sessionId: number,
@Body() dto: UpdateUserSessionTimelineSessionDto,
@Query() query: UserStatisticsFilterQueryDto,
) {
return this.userStatisticsService.updateSessionTimelineSession(user, sessionId, dto, query);
}
@Get('completion-timeline')
getCompletionTimeline(@CurrentUser() user: RequestUser, @Query() query: UserDailyReadingQueryDto) {
return this.userStatisticsService.getCompletionTimeline(user, query);
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, gt, gte, inArray, isNotNull, lt, sql } from 'drizzle-orm';
import { and, eq, gt, gte, inArray, isNotNull, lt, ne, sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type {
@@ -8,7 +8,6 @@ import type {
UserDailyReadingStat,
UserFavoriteDayStat,
UserGenreReadingTimeItem,
UserPeakHourStat,
UserProgressFunnel,
UserReadingPacePoint,
UserSessionArchetypePoint,
@@ -31,6 +30,23 @@ import {
} from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
type SessionTimelineItemRow = {
sessionId: number;
bookId: number;
bookTitle: string | null;
bookFormat: string | null;
startedAt: Date;
endedAt: Date;
durationSeconds: number;
};
type SessionTimelineSessionRow = SessionTimelineItemRow & {
libraryId: number;
};
type SessionTimelineConflictRow = {
sessionId: number;
startedAt: Date;
endedAt: Date;
};
const RECENT_DAILY_AGGREGATION_DAYS = 2;
@Injectable()
@@ -74,6 +90,10 @@ export class UserStatisticsRepository {
return startToday;
}
private formatDayKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
async getSummary(userId: number, isSuperuser: boolean, filterLibraryIds?: number[]): Promise<UserStatisticsSummary> {
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
const libraryIds = this.intersectLibraryIds(accessible, filterLibraryIds);
@@ -135,15 +155,22 @@ export class UserStatisticsRepository {
.orderBy(userReadingDailyStats.day);
}
async getPeakReadingHours(userId: number, isSuperuser: boolean, filterLibraryIds?: number[], days = 365): Promise<UserPeakHourStat[]> {
async getPeakReadingHours(
userId: number,
isSuperuser: boolean,
filterLibraryIds?: number[],
days = 365,
): Promise<{ hour: number; format: string; readingSeconds: number; eventsCount: number }[]> {
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
const libraryFilter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
const since = this.sinceDateForDays(days);
const hourExpr = sql<number>`extract(hour from ${readingSessions.startedAt})::int`;
const formatExpr = sql<string>`upper(coalesce(${bookFiles.format}, 'UNKNOWN'))`;
return this.db
.select({
hour: hourExpr,
format: formatExpr,
readingSeconds: sql<number>`coalesce(sum(${readingSessions.durationSeconds}), 0)::int`,
eventsCount: sql<number>`count(*)::int`,
})
@@ -151,10 +178,251 @@ export class UserStatisticsRepository {
.innerJoin(bookFiles, eq(bookFiles.id, readingSessions.bookFileId))
.innerJoin(books, eq(books.id, bookFiles.bookId))
.where(and(eq(readingSessions.userId, userId), gte(readingSessions.startedAt, since), libraryFilter))
.groupBy(hourExpr)
.groupBy(hourExpr, formatExpr)
.orderBy(hourExpr);
}
async getSessionTimelineItems(
userId: number,
isSuperuser: boolean,
filterLibraryIds: number[] | undefined,
sinceInclusive: Date,
untilExclusive: Date,
limit = 3000,
): Promise<SessionTimelineItemRow[]> {
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
const libraryFilter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
return this.db
.select({
sessionId: readingSessions.id,
bookId: bookFiles.bookId,
bookTitle: bookMetadata.title,
bookFormat: sql<string | null>`nullif(${bookFiles.format}, '')`,
startedAt: readingSessions.startedAt,
endedAt: readingSessions.endedAt,
durationSeconds: readingSessions.durationSeconds,
})
.from(readingSessions)
.innerJoin(bookFiles, eq(bookFiles.id, readingSessions.bookFileId))
.innerJoin(books, eq(books.id, bookFiles.bookId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, bookFiles.bookId))
.where(
and(
eq(readingSessions.userId, userId),
lt(readingSessions.startedAt, untilExclusive),
gt(readingSessions.endedAt, sinceInclusive),
libraryFilter,
),
)
.orderBy(readingSessions.startedAt)
.limit(limit);
}
async getSessionTimelineSessionById(
userId: number,
isSuperuser: boolean,
filterLibraryIds: number[] | undefined,
sessionId: number,
): Promise<SessionTimelineSessionRow | null> {
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
const libraryFilter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
const [row] = await this.db
.select({
sessionId: readingSessions.id,
libraryId: books.libraryId,
bookId: bookFiles.bookId,
bookTitle: bookMetadata.title,
bookFormat: sql<string | null>`nullif(${bookFiles.format}, '')`,
startedAt: readingSessions.startedAt,
endedAt: readingSessions.endedAt,
durationSeconds: readingSessions.durationSeconds,
})
.from(readingSessions)
.innerJoin(bookFiles, eq(bookFiles.id, readingSessions.bookFileId))
.innerJoin(books, eq(books.id, bookFiles.bookId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, bookFiles.bookId))
.where(and(eq(readingSessions.userId, userId), eq(readingSessions.id, sessionId), libraryFilter))
.limit(1);
return row ?? null;
}
async findSessionTimelineConflict(userId: number, sessionId: number, startedAt: Date, endedAt: Date): Promise<SessionTimelineConflictRow | null> {
const [row] = await this.db
.select({
sessionId: readingSessions.id,
startedAt: readingSessions.startedAt,
endedAt: readingSessions.endedAt,
})
.from(readingSessions)
.where(
and(
eq(readingSessions.userId, userId),
ne(readingSessions.id, sessionId),
lt(readingSessions.startedAt, endedAt),
gt(readingSessions.endedAt, startedAt),
),
)
.orderBy(readingSessions.startedAt)
.limit(1);
return row ?? null;
}
async updateSessionTimelineSession(userId: number, sessionId: number, startedAt: Date, endedAt: Date): Promise<SessionTimelineSessionRow | null> {
const durationSeconds = Math.round((endedAt.getTime() - startedAt.getTime()) / 1000);
const updated = await this.db
.update(readingSessions)
.set({ startedAt, endedAt, durationSeconds })
.where(and(eq(readingSessions.userId, userId), eq(readingSessions.id, sessionId)))
.returning({ id: readingSessions.id });
if (updated.length === 0) return null;
return this.getSessionTimelineSessionById(userId, true, undefined, sessionId);
}
async moveSessionTimelineSessionAtomic(
userId: number,
sessionId: number,
libraryId: number,
previousStartedAt: Date,
startedAt: Date,
endedAt: Date,
durationSeconds: number,
): Promise<{ updated: SessionTimelineSessionRow | null; conflict: SessionTimelineConflictRow | null }> {
return this.db.transaction(async (tx) => {
// Serialize edits per user to avoid race conditions between concurrent drags.
await tx.execute(sql`select pg_advisory_xact_lock(${userId}::bigint)`);
const [conflict] = await tx
.select({
sessionId: readingSessions.id,
startedAt: readingSessions.startedAt,
endedAt: readingSessions.endedAt,
})
.from(readingSessions)
.where(
and(
eq(readingSessions.userId, userId),
ne(readingSessions.id, sessionId),
lt(readingSessions.startedAt, endedAt),
gt(readingSessions.endedAt, startedAt),
),
)
.orderBy(readingSessions.startedAt)
.limit(1);
if (conflict) return { updated: null, conflict };
const touched = await tx
.update(readingSessions)
.set({ startedAt, endedAt, durationSeconds })
.where(and(eq(readingSessions.userId, userId), eq(readingSessions.id, sessionId)))
.returning({ id: readingSessions.id });
if (touched.length === 0) return { updated: null, conflict: null };
const uniqueDays = [...new Set([this.formatDayKey(previousStartedAt), this.formatDayKey(startedAt)])];
if (uniqueDays.length > 0) {
const dayDateList = sql.join(
uniqueDays.map((day) => sql`${day}::date`),
sql`, `,
);
await tx
.delete(userReadingDailyStats)
.where(
and(
eq(userReadingDailyStats.userId, userId),
eq(userReadingDailyStats.libraryId, libraryId),
inArray(userReadingDailyStats.day, uniqueDays),
),
);
await tx.execute(sql`
insert into user_reading_daily_stats (user_id, library_id, day, reading_seconds, progress_delta, sessions_count, updated_at)
select
rs.user_id,
b.library_id,
date_trunc('day', rs.started_at)::date as day,
coalesce(sum(rs.duration_seconds), 0)::int as reading_seconds,
coalesce(sum(rs.progress_delta), 0)::real as progress_delta,
count(*)::int as sessions_count,
now() as updated_at
from reading_sessions rs
inner join book_files bf on bf.id = rs.book_file_id
inner join books b on b.id = bf.book_id
where rs.user_id = ${userId}
and b.library_id = ${libraryId}
and date_trunc('day', rs.started_at)::date in (${dayDateList})
group by rs.user_id, b.library_id, date_trunc('day', rs.started_at)::date
`);
}
const [updated] = await tx
.select({
sessionId: readingSessions.id,
libraryId: books.libraryId,
bookId: bookFiles.bookId,
bookTitle: bookMetadata.title,
bookFormat: sql<string | null>`nullif(${bookFiles.format}, '')`,
startedAt: readingSessions.startedAt,
endedAt: readingSessions.endedAt,
durationSeconds: readingSessions.durationSeconds,
})
.from(readingSessions)
.innerJoin(bookFiles, eq(bookFiles.id, readingSessions.bookFileId))
.innerJoin(books, eq(books.id, bookFiles.bookId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, bookFiles.bookId))
.where(and(eq(readingSessions.userId, userId), eq(readingSessions.id, sessionId)))
.limit(1);
return { updated: updated ?? null, conflict: null };
});
}
async recomputeUserDailyStatsForLibraryDays(userId: number, libraryId: number, days: string[]): Promise<void> {
const uniqueDays = [...new Set(days)];
if (uniqueDays.length === 0) return;
const dayDateList = sql.join(
uniqueDays.map((day) => sql`${day}::date`),
sql`, `,
);
await this.db.transaction(async (tx) => {
await tx
.delete(userReadingDailyStats)
.where(
and(
eq(userReadingDailyStats.userId, userId),
eq(userReadingDailyStats.libraryId, libraryId),
inArray(userReadingDailyStats.day, uniqueDays),
),
);
await tx.execute(sql`
insert into user_reading_daily_stats (user_id, library_id, day, reading_seconds, progress_delta, sessions_count, updated_at)
select
rs.user_id,
b.library_id,
date_trunc('day', rs.started_at)::date as day,
coalesce(sum(rs.duration_seconds), 0)::int as reading_seconds,
coalesce(sum(rs.progress_delta), 0)::real as progress_delta,
count(*)::int as sessions_count,
now() as updated_at
from reading_sessions rs
inner join book_files bf on bf.id = rs.book_file_id
inner join books b on b.id = bf.book_id
where rs.user_id = ${userId}
and b.library_id = ${libraryId}
and date_trunc('day', rs.started_at)::date in (${dayDateList})
group by rs.user_id, b.library_id, date_trunc('day', rs.started_at)::date
`);
});
}
async getFavoriteReadingDays(userId: number, isSuperuser: boolean, filterLibraryIds?: number[], days = 365): Promise<UserFavoriteDayStat[]> {
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
const libraryFilter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
@@ -64,9 +64,9 @@ describe('UserStatisticsService', () => {
expect(repo.getPeakReadingHours).toHaveBeenCalledWith(123, false, [], 365);
expect(result).toHaveLength(24);
expect(result[8]).toEqual({ hour: 8, readingSeconds: 600, eventsCount: 3 });
expect(result[21]).toEqual({ hour: 21, readingSeconds: 900, eventsCount: 4 });
expect(result[0]).toEqual({ hour: 0, readingSeconds: 0, eventsCount: 0 });
expect(result[8]).toEqual(expect.objectContaining({ hour: 8, readingSeconds: 600, eventsCount: 3 }));
expect(result[21]).toEqual(expect.objectContaining({ hour: 21, readingSeconds: 900, eventsCount: 4 }));
expect(result[0]).toEqual(expect.objectContaining({ hour: 0, readingSeconds: 0, eventsCount: 0 }));
});
it('returns all weekday buckets for favorite reading days', async () => {
@@ -232,4 +232,161 @@ describe('UserStatisticsService', () => {
previous: { started: 18, reached25: 13, reached50: 9, reached75: 6, completed: 4 },
});
});
it('returns weekly session timeline with ISO week defaults', async () => {
const repo = {
getSessionTimelineItems: vi.fn().mockResolvedValue([
{
sessionId: 100,
bookId: 55,
bookTitle: 'Deep Work',
bookFormat: 'EPUB',
startedAt: new Date('2026-04-07T02:30:00.000Z'),
endedAt: new Date('2026-04-07T03:00:00.000Z'),
durationSeconds: 1800,
},
]),
};
const service = new UserStatisticsService(repo as any);
const result = await service.getSessionTimeline({ id: 123, isSuperuser: false } as any, { libraryIds: [1] });
expect(result.year).toBe(2026);
expect(result.week).toBe(15);
expect(result.weekStart).toBe('2026-04-06');
expect(result.weekEnd).toBe('2026-04-12');
expect(result.items[0]).toEqual({
sessionId: 100,
bookId: 55,
bookTitle: 'Deep Work',
bookFormat: 'EPUB',
startedAt: '2026-04-07T02:30:00.000Z',
endedAt: '2026-04-07T03:00:00.000Z',
durationSeconds: 1800,
});
expect(repo.getSessionTimelineItems).toHaveBeenCalledTimes(1);
const [, , , since, until, limit] = repo.getSessionTimelineItems.mock.calls[0];
expect(since.toISOString()).toBe('2026-04-06T00:00:00.000Z');
expect(until.toISOString()).toBe('2026-04-13T00:00:00.000Z');
expect(limit).toBe(3000);
});
it('updates a timeline session atomically', async () => {
const existing = {
sessionId: 101,
libraryId: 4,
bookId: 44,
bookTitle: 'Atomic Habits',
bookFormat: 'EPUB',
startedAt: new Date('2026-04-07T08:00:00.000Z'),
endedAt: new Date('2026-04-07T08:30:00.000Z'),
durationSeconds: 1800,
};
const updated = {
...existing,
startedAt: new Date('2026-04-08T09:00:00.000Z'),
endedAt: new Date('2026-04-08T09:30:00.000Z'),
};
const repo = {
getSessionTimelineSessionById: vi.fn().mockResolvedValue(existing),
moveSessionTimelineSessionAtomic: vi.fn().mockResolvedValue({
updated,
conflict: null,
}),
};
const service = new UserStatisticsService(repo as any);
const result = await service.updateSessionTimelineSession(
{ id: 123, isSuperuser: false } as any,
101,
{
startedAt: '2026-04-08T09:00:00.000Z',
endedAt: '2026-04-08T09:30:00.000Z',
},
{ libraryIds: [4] },
);
expect(repo.moveSessionTimelineSessionAtomic).toHaveBeenCalledWith(
123,
101,
4,
new Date('2026-04-07T08:00:00.000Z'),
new Date('2026-04-08T09:00:00.000Z'),
new Date('2026-04-08T09:30:00.000Z'),
1800,
);
expect(result.startedAt).toBe('2026-04-08T09:00:00.000Z');
expect(result.endedAt).toBe('2026-04-08T09:30:00.000Z');
});
it('rejects session moves that overlap another session', async () => {
const existing = {
sessionId: 42,
libraryId: 3,
bookId: 77,
bookTitle: 'Flow',
bookFormat: 'PDF',
startedAt: new Date('2026-04-07T10:00:00.000Z'),
endedAt: new Date('2026-04-07T10:30:00.000Z'),
durationSeconds: 1800,
};
const repo = {
getSessionTimelineSessionById: vi.fn().mockResolvedValue(existing),
moveSessionTimelineSessionAtomic: vi.fn().mockResolvedValue({
updated: null,
conflict: {
sessionId: 88,
startedAt: new Date('2026-04-07T10:10:00.000Z'),
endedAt: new Date('2026-04-07T10:40:00.000Z'),
},
}),
};
const service = new UserStatisticsService(repo as any);
await expect(
service.updateSessionTimelineSession(
{ id: 123, isSuperuser: false } as any,
42,
{
startedAt: '2026-04-07T10:05:00.000Z',
endedAt: '2026-04-07T10:35:00.000Z',
},
{ libraryIds: [3] },
),
).rejects.toThrow('overlaps with #88');
});
it('rejects session moves that change duration', async () => {
const existing = {
sessionId: 52,
libraryId: 5,
bookId: 90,
bookTitle: 'Ultralearning',
bookFormat: 'EPUB',
startedAt: new Date('2026-04-07T10:00:00.000Z'),
endedAt: new Date('2026-04-07T10:30:00.000Z'),
durationSeconds: 1800,
};
const repo = {
getSessionTimelineSessionById: vi.fn().mockResolvedValue(existing),
moveSessionTimelineSessionAtomic: vi.fn(),
};
const service = new UserStatisticsService(repo as any);
await expect(
service.updateSessionTimelineSession(
{ id: 123, isSuperuser: false } as any,
52,
{
startedAt: '2026-04-07T10:05:00.000Z',
endedAt: '2026-04-07T10:36:00.000Z',
},
{ libraryIds: [5] },
),
).rejects.toThrow('duration cannot change');
expect(repo.moveSessionTimelineSessionAtomic).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import type {
ChordDiagramData,
@@ -14,6 +14,8 @@ import type {
UserProgressFunnelComparison,
UserProgressFunnel,
UserReadingPacePoint,
UserReadingSessionTimeline,
UserReadingSessionTimelineItem,
UserReadingSurvivalPoint,
UserSessionArchetypePoint,
UserStatisticsSummary,
@@ -22,6 +24,8 @@ import type {
import type { RequestUser } from '../../common/types/request-user';
import type { UserDailyReadingQueryDto } from './dto/user-daily-reading-query.dto';
import type { UserGoalTrajectoryQueryDto } from './dto/user-goal-trajectory-query.dto';
import type { UserSessionTimelineQueryDto } from './dto/user-session-timeline-query.dto';
import type { UpdateUserSessionTimelineSessionDto } from './dto/update-user-session-timeline-session.dto';
import type { UserStatisticsFilterQueryDto } from './dto/user-statistics-filter-query.dto';
import { UserStatisticsRepository } from './user-statistics.repository';
@@ -31,6 +35,7 @@ const COMPLETION_TIMELINE_DEFAULT_DAYS = 1825;
const GOAL_TRAJECTORY_DEFAULT_DAYS = 365;
const GOAL_TRAJECTORY_DEFAULT_GOAL_BOOKS = 12;
const PROGRESS_FUNNEL_DEFAULT_DAYS = 365;
const SESSION_TIMELINE_MAX_SESSIONS = 3000;
const COMPLETION_LATENCY_DEFAULT_DAYS = 1825;
const GENRE_READING_TIME_DEFAULT_DAYS = 365;
const READING_PACE_DEFAULT_DAYS = 1825;
@@ -59,6 +64,42 @@ export class UserStatisticsService {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
}
private startOfUtcIsoWeek(date: Date): Date {
const start = this.startOfUtcDay(date);
const day = (start.getUTCDay() + 6) % 7; // Mon=0 ... Sun=6
start.setUTCDate(start.getUTCDate() - day);
return start;
}
private getUtcIsoWeekYear(date: Date): number {
const d = this.startOfUtcDay(date);
const day = (d.getUTCDay() + 6) % 7;
d.setUTCDate(d.getUTCDate() - day + 3); // Thursday
return d.getUTCFullYear();
}
private getUtcIsoWeek(date: Date): number {
const d = this.startOfUtcDay(date);
const day = (d.getUTCDay() + 6) % 7;
d.setUTCDate(d.getUTCDate() - day + 3); // Thursday
const firstThursday = new Date(Date.UTC(d.getUTCFullYear(), 0, 4));
const firstDay = (firstThursday.getUTCDay() + 6) % 7;
firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDay + 3);
return 1 + Math.round((d.getTime() - firstThursday.getTime()) / 604_800_000);
}
private getUtcIsoWeeksInYear(year: number): number {
return this.getUtcIsoWeek(new Date(Date.UTC(year, 11, 28)));
}
private getUtcIsoWeekStart(year: number, week: number): Date {
const jan4 = new Date(Date.UTC(year, 0, 4));
const week1Start = this.startOfUtcIsoWeek(jan4);
const weekStart = new Date(week1Start);
weekStart.setUTCDate(week1Start.getUTCDate() + (week - 1) * 7);
return weekStart;
}
private sinceDateForDays(days: number): Date {
const normalized = Number.isFinite(days) ? Math.max(1, Math.floor(days)) : 1;
const startToday = this.startOfUtcDay(new Date());
@@ -172,14 +213,25 @@ export class UserStatisticsService {
const key = this.cacheKey('peak-hours', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days });
return this.withCache(key, QUERY_CACHE_TTL_MS, async () => {
const rows = await this.repo.getPeakReadingHours(user.id, user.isSuperuser, query.libraryIds, days);
const byHour = new Map(rows.map((row) => [row.hour, row]));
const byHour = new Map<number, { readingSeconds: number; eventsCount: number; byFormat: Record<string, number> }>();
for (const row of rows) {
if (!byHour.has(row.hour)) {
byHour.set(row.hour, { readingSeconds: 0, eventsCount: 0, byFormat: {} });
}
const entry = byHour.get(row.hour)!;
entry.readingSeconds += row.readingSeconds;
entry.eventsCount += row.eventsCount;
entry.byFormat[row.format] = row.readingSeconds;
}
return Array.from({ length: 24 }, (_, hour) => {
const row = byHour.get(hour);
const entry = byHour.get(hour);
return {
hour,
readingSeconds: row?.readingSeconds ?? 0,
eventsCount: row?.eventsCount ?? 0,
readingSeconds: entry?.readingSeconds ?? 0,
eventsCount: entry?.eventsCount ?? 0,
byFormat: entry?.byFormat ?? {},
};
});
});
@@ -203,6 +255,112 @@ export class UserStatisticsService {
});
}
private toTimelineItem(row: {
sessionId: number;
bookId: number;
bookTitle: string | null;
bookFormat: string | null;
startedAt: Date;
endedAt: Date;
durationSeconds: number;
}): UserReadingSessionTimelineItem {
return {
sessionId: row.sessionId,
bookId: row.bookId,
bookTitle: row.bookTitle,
bookFormat: row.bookFormat,
startedAt: row.startedAt.toISOString(),
endedAt: row.endedAt.toISOString(),
durationSeconds: row.durationSeconds,
};
}
async getSessionTimeline(user: RequestUser, query: UserSessionTimelineQueryDto): Promise<UserReadingSessionTimeline> {
const now = new Date();
const defaultYear = this.getUtcIsoWeekYear(now);
const defaultWeek = this.getUtcIsoWeek(now);
const year = query.year ?? defaultYear;
const weeksInYear = this.getUtcIsoWeeksInYear(year);
const week = Math.min(Math.max(query.week ?? defaultWeek, 1), weeksInYear);
const weekStart = this.getUtcIsoWeekStart(year, week);
const weekEndExclusive = new Date(weekStart);
weekEndExclusive.setUTCDate(weekEndExclusive.getUTCDate() + 7);
const key = this.cacheKey('session-timeline', user, {
libraries: this.normalizeLibraryIds(query.libraryIds),
year,
week,
});
return this.withCache(key, QUERY_CACHE_TTL_MS, async () => {
const rows = await this.repo.getSessionTimelineItems(
user.id,
user.isSuperuser,
query.libraryIds,
weekStart,
weekEndExclusive,
SESSION_TIMELINE_MAX_SESSIONS,
);
const weekEnd = new Date(weekStart);
weekEnd.setUTCDate(weekEnd.getUTCDate() + 6);
return {
year,
week,
weekStart: this.formatDayKey(weekStart),
weekEnd: this.formatDayKey(weekEnd),
items: rows.map((row) => this.toTimelineItem(row)),
};
});
}
async updateSessionTimelineSession(
user: RequestUser,
sessionId: number,
dto: UpdateUserSessionTimelineSessionDto,
query: UserStatisticsFilterQueryDto,
): Promise<UserReadingSessionTimelineItem> {
const startedAt = new Date(dto.startedAt);
const endedAt = new Date(dto.endedAt);
if (!Number.isFinite(startedAt.getTime()) || !Number.isFinite(endedAt.getTime())) {
throw new BadRequestException('Invalid session timestamps');
}
if (endedAt <= startedAt) {
throw new BadRequestException('Session end time must be after start time');
}
const existing = await this.repo.getSessionTimelineSessionById(user.id, user.isSuperuser, query.libraryIds, sessionId);
if (!existing) {
throw new NotFoundException('Reading session not found');
}
const proposedDuration = Math.round((endedAt.getTime() - startedAt.getTime()) / 1000);
if (proposedDuration !== existing.durationSeconds) {
throw new BadRequestException('Dragging can move a session only; duration cannot change');
}
const moveResult = await this.repo.moveSessionTimelineSessionAtomic(
user.id,
sessionId,
existing.libraryId,
existing.startedAt,
startedAt,
endedAt,
proposedDuration,
);
if (moveResult.conflict) {
const conflictStart = moveResult.conflict.startedAt.toISOString();
throw new ConflictException(`Session overlaps with #${moveResult.conflict.sessionId} starting at ${conflictStart}`);
}
if (!moveResult.updated) {
throw new NotFoundException('Reading session not found');
}
this.queryCache.clear();
return this.toTimelineItem(moveResult.updated);
}
async getCompletionTimeline(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserCompletionTimelinePoint[]> {
const days = query.days ?? COMPLETION_TIMELINE_DEFAULT_DAYS;
const key = this.cacheKey('completion-timeline', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days });