From 19b0c4d4c83b12cbd2e3af3981c1eb27447a4174 Mon Sep 17 00:00:00 2001 From: cnliucheng Date: Wed, 29 Jul 2026 14:18:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B6=8B=E5=8A=BF=E5=9B=BE?= =?UTF-8?q?=E8=A1=A8=E5=8A=A0=E8=BD=BD=E4=B8=8E=E6=AF=94=E4=BE=8B=E5=B1=95?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/trend-chart/trend-chart.js | 8 +-- components/trend-chart/trend-chart.wxss | 4 +- pages/records/records.js | 41 +-------------- tests/trend-state.test.js | 47 +++++++++++++++++ utils/util.js | 69 +++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 45 deletions(-) create mode 100644 tests/trend-state.test.js diff --git a/components/trend-chart/trend-chart.js b/components/trend-chart/trend-chart.js index afb4556..e4797ac 100644 --- a/components/trend-chart/trend-chart.js +++ b/components/trend-chart/trend-chart.js @@ -1,3 +1,5 @@ +const { getBarPercent } = require('../../utils/util') + /** * Trend bar chart - pure CSS (no canvas), matches the calendar-heatmap's * rendering approach for consistency. @@ -8,8 +10,8 @@ * - valueText: formatted string shown above the bar (e.g. "1分30秒") * - 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 - * short sessions stay visible. Zero values render a faint stub. + * Bar height is strictly proportional to value/max. Zero values render a + * faint stub so empty dates remain visible without distorting the data. */ Component({ properties: { @@ -39,7 +41,7 @@ Component({ value: v, valueText: (d && d.valueText) || '', highlight: !!(d && d.highlight), - percent: max > 0 ? Math.max((v / max) * 100, v > 0 ? 8 : 0) : 0 + percent: getBarPercent(v, max) } }) this.setData({ bars }) diff --git a/components/trend-chart/trend-chart.wxss b/components/trend-chart/trend-chart.wxss index 2ac4f91..2f0e976 100644 --- a/components/trend-chart/trend-chart.wxss +++ b/components/trend-chart/trend-chart.wxss @@ -39,11 +39,11 @@ /* value caption floats above the bar so it never eats into bar height */ .trend-bar-value { position: absolute; - top: -28rpx; + top: -26rpx; left: 0; right: 0; text-align: center; - font-size: 18rpx; + font-size: 16rpx; color: var(--text-secondary); line-height: 1; white-space: nowrap; diff --git a/pages/records/records.js b/pages/records/records.js index 839f28a..a7e3542 100644 --- a/pages/records/records.js +++ b/pages/records/records.js @@ -168,46 +168,7 @@ Page({ * 返回 [{ label, value, valueText, highlight }] 供 trend-chart 渲染。 */ _computeTrend(mode) { - const allRecords = Object.values(storage.getRecords()).flat() - 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 + return util.getTrendData(storage.getRecords(), mode) }, onDayTap(e) { diff --git a/tests/trend-state.test.js b/tests/trend-state.test.js new file mode 100644 index 0000000..48dfb23 --- /dev/null +++ b/tests/trend-state.test.js @@ -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) +}) diff --git a/utils/util.js b/utils/util.js index da2a73c..dc8fed5 100644 --- a/utils/util.js +++ b/utils/util.js @@ -54,6 +54,73 @@ const formatDate = (date) => { /** 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 _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 * base64 data URI. Used to make temporary file URLs survive the WeChat @@ -101,5 +168,7 @@ module.exports = { getMonthCalendar, formatDate, dateOnly: _dateOnly, + getTrendData, + getBarPercent, fileToDataURI }