修复趋势图表加载与比例展示
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
const { getBarPercent } = require('../../utils/util')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trend bar chart - pure CSS (no canvas), matches the calendar-heatmap's
|
* Trend bar chart - pure CSS (no canvas), matches the calendar-heatmap's
|
||||||
* rendering approach for consistency.
|
* rendering approach for consistency.
|
||||||
@@ -8,8 +10,8 @@
|
|||||||
* - valueText: formatted string shown above the bar (e.g. "1分30秒")
|
* - valueText: formatted string shown above the bar (e.g. "1分30秒")
|
||||||
* - highlight: truthy -> theme glow + bold label (today / this month)
|
* - highlight: truthy -> theme glow + bold label (today / this month)
|
||||||
*
|
*
|
||||||
* Bar height is value/max*100%, with an 8% floor for any non-zero value so
|
* Bar height is strictly proportional to value/max. Zero values render a
|
||||||
* short sessions stay visible. Zero values render a faint stub.
|
* faint stub so empty dates remain visible without distorting the data.
|
||||||
*/
|
*/
|
||||||
Component({
|
Component({
|
||||||
properties: {
|
properties: {
|
||||||
@@ -39,7 +41,7 @@ Component({
|
|||||||
value: v,
|
value: v,
|
||||||
valueText: (d && d.valueText) || '',
|
valueText: (d && d.valueText) || '',
|
||||||
highlight: !!(d && d.highlight),
|
highlight: !!(d && d.highlight),
|
||||||
percent: max > 0 ? Math.max((v / max) * 100, v > 0 ? 8 : 0) : 0
|
percent: getBarPercent(v, max)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
this.setData({ bars })
|
this.setData({ bars })
|
||||||
|
|||||||
@@ -39,11 +39,11 @@
|
|||||||
/* value caption floats above the bar so it never eats into bar height */
|
/* value caption floats above the bar so it never eats into bar height */
|
||||||
.trend-bar-value {
|
.trend-bar-value {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -28rpx;
|
top: -26rpx;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 18rpx;
|
font-size: 16rpx;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -168,46 +168,7 @@ Page({
|
|||||||
* 返回 [{ label, value, valueText, highlight }] 供 trend-chart 渲染。
|
* 返回 [{ label, value, valueText, highlight }] 供 trend-chart 渲染。
|
||||||
*/
|
*/
|
||||||
_computeTrend(mode) {
|
_computeTrend(mode) {
|
||||||
const allRecords = Object.values(storage.getRecords()).flat()
|
return util.getTrendData(storage.getRecords(), mode)
|
||||||
const today = new Date()
|
|
||||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
|
||||||
const pad = (n) => String(n).padStart(2, '0')
|
|
||||||
|
|
||||||
if (mode === 'month') {
|
|
||||||
const data = []
|
|
||||||
for (let i = 5; i >= 0; i--) {
|
|
||||||
const d = new Date(today.getFullYear(), today.getMonth() - i, 1)
|
|
||||||
const monthKey = `${d.getFullYear()}-${pad(d.getMonth() + 1)}`
|
|
||||||
const monthRecords = storage.getRecordsByMonth(monthKey) || []
|
|
||||||
const total = monthRecords.reduce((s, r) => s + (Number(r.duration) || 0), 0)
|
|
||||||
data.push({
|
|
||||||
label: `${d.getMonth() + 1}月`,
|
|
||||||
value: total,
|
|
||||||
valueText: total > 0 ? util.formatDuration(total) : '',
|
|
||||||
highlight: i === 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// default: week (近 7 天)
|
|
||||||
const data = []
|
|
||||||
for (let i = 6; i >= 0; i--) {
|
|
||||||
const d = new Date(today)
|
|
||||||
d.setDate(d.getDate() - i)
|
|
||||||
const dateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
||||||
const total = allRecords
|
|
||||||
.filter((r) => util.dateOnly(r.date) === dateStr)
|
|
||||||
.reduce((s, r) => s + (Number(r.duration) || 0), 0)
|
|
||||||
const isToday = i === 0
|
|
||||||
data.push({
|
|
||||||
label: isToday ? '今天' : weekdays[d.getDay()],
|
|
||||||
value: total,
|
|
||||||
valueText: total > 0 ? util.formatDuration(total) : '',
|
|
||||||
highlight: isToday
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onDayTap(e) {
|
onDayTap(e) {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const { getTrendData, getBarPercent } = require('../utils/util')
|
||||||
|
|
||||||
|
test('weekly trend combines multiple sessions from the same day', () => {
|
||||||
|
const records = {
|
||||||
|
'2026-07': [
|
||||||
|
{ date: '2026-07-29 08:00:00', duration: 30 },
|
||||||
|
{ date: '2026-07-29 20:00:00', duration: 45 },
|
||||||
|
{ date: '2026-07-23 08:00:00', duration: 60 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(getTrendData(records, 'week', new Date(2026, 6, 29)), [
|
||||||
|
{ label: '四', value: 60, valueText: '01:00', highlight: false },
|
||||||
|
{ label: '五', value: 0, valueText: '', highlight: false },
|
||||||
|
{ label: '六', value: 0, valueText: '', highlight: false },
|
||||||
|
{ label: '日', value: 0, valueText: '', highlight: false },
|
||||||
|
{ label: '一', value: 0, valueText: '', highlight: false },
|
||||||
|
{ label: '二', value: 0, valueText: '', highlight: false },
|
||||||
|
{ label: '今天', value: 75, valueText: '01:15', highlight: true }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('monthly trend spans a year boundary from a single record snapshot', () => {
|
||||||
|
const records = {
|
||||||
|
'2025-08': [{ date: '2025-08-31 08:00:00', duration: 60 }],
|
||||||
|
'2025-12': [{ date: '2025-12-01 08:00:00', duration: 120 }],
|
||||||
|
'2026-01': [{ date: '2026-01-02 08:00:00', duration: 3600 }]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(getTrendData(records, 'month', new Date(2026, 0, 15)), [
|
||||||
|
{ label: '8月', value: 60, valueText: '01:00', highlight: false },
|
||||||
|
{ label: '9月', value: 0, valueText: '', highlight: false },
|
||||||
|
{ label: '10月', value: 0, valueText: '', highlight: false },
|
||||||
|
{ label: '11月', value: 0, valueText: '', highlight: false },
|
||||||
|
{ label: '12月', value: 120, valueText: '02:00', highlight: false },
|
||||||
|
{ label: '1月', value: 3600, valueText: '60m', highlight: true }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bar height remains proportional for very short sessions', () => {
|
||||||
|
assert.equal(getBarPercent(0, 120), 0)
|
||||||
|
assert.ok(getBarPercent(1, 120) < 1)
|
||||||
|
assert.equal(getBarPercent(120, 120), 100)
|
||||||
|
})
|
||||||
@@ -54,6 +54,73 @@ const formatDate = (date) => {
|
|||||||
/** Strip the time portion of a formatDate string → "YYYY-MM-DD". */
|
/** Strip the time portion of a formatDate string → "YYYY-MM-DD". */
|
||||||
const _dateOnly = (s) => (typeof s === 'string' && s.length >= 10) ? s.substring(0, 10) : ''
|
const _dateOnly = (s) => (typeof s === 'string' && s.length >= 10) ? s.substring(0, 10) : ''
|
||||||
|
|
||||||
|
const _normalizeDuration = (value) => Math.max(0, Math.floor(Number(value) || 0))
|
||||||
|
|
||||||
|
const _formatTrendDuration = (seconds) => {
|
||||||
|
const duration = _normalizeDuration(seconds)
|
||||||
|
if (duration >= 3600) return `${Math.floor(duration / 60)}m`
|
||||||
|
return formatTime(duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build daily or monthly duration totals from one training-record snapshot. */
|
||||||
|
const getTrendData = (recordGroups, mode, now = new Date()) => {
|
||||||
|
const dateTotals = {}
|
||||||
|
const groups = recordGroups && typeof recordGroups === 'object' ? recordGroups : {}
|
||||||
|
|
||||||
|
Object.values(groups).forEach((records) => {
|
||||||
|
if (!Array.isArray(records)) return
|
||||||
|
records.forEach((record) => {
|
||||||
|
const date = _dateOnly(record && record.date)
|
||||||
|
if (!date) return
|
||||||
|
dateTotals[date] = (dateTotals[date] || 0) + _normalizeDuration(record.duration)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const today = new Date(now)
|
||||||
|
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
|
||||||
|
if (mode === 'month') {
|
||||||
|
const monthTotals = {}
|
||||||
|
Object.keys(dateTotals).forEach((date) => {
|
||||||
|
const month = date.substring(0, 7)
|
||||||
|
monthTotals[month] = (monthTotals[month] || 0) + dateTotals[date]
|
||||||
|
})
|
||||||
|
return Array.from({ length: 6 }, (_, index) => {
|
||||||
|
const offset = 5 - index
|
||||||
|
const date = new Date(today.getFullYear(), today.getMonth() - offset, 1)
|
||||||
|
const monthKey = `${date.getFullYear()}-${pad(date.getMonth() + 1)}`
|
||||||
|
const value = monthTotals[monthKey] || 0
|
||||||
|
return {
|
||||||
|
label: `${date.getMonth() + 1}月`,
|
||||||
|
value,
|
||||||
|
valueText: value > 0 ? _formatTrendDuration(value) : '',
|
||||||
|
highlight: offset === 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from({ length: 7 }, (_, index) => {
|
||||||
|
const offset = 6 - index
|
||||||
|
const date = new Date(today)
|
||||||
|
date.setDate(date.getDate() - offset)
|
||||||
|
const dateKey = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||||
|
const value = dateTotals[dateKey] || 0
|
||||||
|
return {
|
||||||
|
label: offset === 0 ? '今天' : weekdays[date.getDay()],
|
||||||
|
value,
|
||||||
|
valueText: value > 0 ? _formatTrendDuration(value) : '',
|
||||||
|
highlight: offset === 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBarPercent = (value, max) => {
|
||||||
|
const amount = _normalizeDuration(value)
|
||||||
|
const maximum = _normalizeDuration(max)
|
||||||
|
return maximum > 0 ? amount / maximum * 100 : 0
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a local file path (typically `wxfile://...` from chooseAvatar) to a
|
* Convert a local file path (typically `wxfile://...` from chooseAvatar) to a
|
||||||
* base64 data URI. Used to make temporary file URLs survive the WeChat
|
* base64 data URI. Used to make temporary file URLs survive the WeChat
|
||||||
@@ -101,5 +168,7 @@ module.exports = {
|
|||||||
getMonthCalendar,
|
getMonthCalendar,
|
||||||
formatDate,
|
formatDate,
|
||||||
dateOnly: _dateOnly,
|
dateOnly: _dateOnly,
|
||||||
|
getTrendData,
|
||||||
|
getBarPercent,
|
||||||
fileToDataURI
|
fileToDataURI
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user