diff --git a/components/trend-chart/trend-chart.js b/components/trend-chart/trend-chart.js index e4797ac..a55e3ce 100644 --- a/components/trend-chart/trend-chart.js +++ b/components/trend-chart/trend-chart.js @@ -15,7 +15,8 @@ const { getBarPercent } = require('../../utils/util') */ Component({ properties: { - chartData: { type: Array, value: [] } + chartData: { type: Array, value: [] }, + variant: { type: String, value: 'total' } }, data: { bars: [] diff --git a/components/trend-chart/trend-chart.wxml b/components/trend-chart/trend-chart.wxml index c4ca74b..d70ff78 100644 --- a/components/trend-chart/trend-chart.wxml +++ b/components/trend-chart/trend-chart.wxml @@ -1,4 +1,4 @@ - + diff --git a/components/trend-chart/trend-chart.wxss b/components/trend-chart/trend-chart.wxss index 2f0e976..8fcb665 100644 --- a/components/trend-chart/trend-chart.wxss +++ b/components/trend-chart/trend-chart.wxss @@ -36,6 +36,32 @@ box-shadow: 0 0 14rpx rgba(var(--primary-rgb), 0.5); } +.trend-chart.peak .trend-bars { + height: 156rpx; +} + +.trend-chart.peak .trend-bar { + width: 12rpx; + margin: 0 auto; + min-height: 8rpx; + border-radius: 999rpx; +} + +.trend-chart.peak .trend-bar::after { + content: ''; + position: absolute; + top: -8rpx; + left: -4rpx; + width: 20rpx; + height: 20rpx; + border-radius: 50%; + background: var(--primary); +} + +.trend-chart.peak .trend-bar.zero::after { + background: var(--border); +} + /* value caption floats above the bar so it never eats into bar height */ .trend-bar-value { position: absolute; diff --git a/pages/records/records.js b/pages/records/records.js index a7e3542..cd3d997 100644 --- a/pages/records/records.js +++ b/pages/records/records.js @@ -29,8 +29,12 @@ Page({ monthRecords: [], historyList: [], listMode: 'month', + contentMode: 'records', trendMode: 'week', trendData: [], + trendSummary: {}, + bestTrendData: [], + bestTrendSummary: {}, showDayDetail: false, dayDetail: {}, icons: iconsMod.build() @@ -61,7 +65,7 @@ Page({ }, refresh() { - storage.validateStreak() + const streak = storage.validateStreak() const stats = storage.getTotalStats() const monthKey = `${this.data.currentYear}-${String(this.data.currentMonth).padStart(2, '0')}` const records = (storage.getRecordsByMonth(monthKey) || []).map(r => ({ ...r, durationText: util.formatTime(r.duration) })) @@ -103,6 +107,9 @@ Page({ : `还差 ${badgeDefs[nextIdx].days - totalDays} 天解锁「${badgeDefs[nextIdx].name}」` const theme = themeMod.getCurrentTheme() + const trendMode = this.data.trendMode || 'week' + const trendData = this._computeTrend(trendMode) + const bestTrendData = this._computeBestTrend(trendMode) this.setData({ theme, icons: iconsMod.build(theme), @@ -119,7 +126,10 @@ Page({ historyList, listMode: this.data.listMode || 'month', displayList: (this.data.listMode || 'month') === 'month' ? records : historyList, - trendData: this._computeTrend(this.data.trendMode || 'week'), + trendData, + trendSummary: { ...util.getTrendSummary(trendData, trendMode), streakCount: Number(streak.count) || 0 }, + bestTrendData, + bestTrendSummary: util.getTrendSummary(bestTrendData, trendMode), loading: false }) }, @@ -155,10 +165,24 @@ Page({ this.refresh() }, + onToggleContentMode(e) { + const mode = e.currentTarget.dataset.mode + if (!['records', 'trend'].includes(mode) || mode === this.data.contentMode) return + this.setData({ contentMode: mode }) + }, + onToggleTrendMode(e) { const mode = e.currentTarget.dataset.mode if (!mode || mode === this.data.trendMode) return - this.setData({ trendMode: mode, trendData: this._computeTrend(mode) }) + const trendData = this._computeTrend(mode) + const bestTrendData = this._computeBestTrend(mode) + this.setData({ + trendMode: mode, + trendData, + trendSummary: { ...util.getTrendSummary(trendData, mode), streakCount: Number(storage.getStreak().count) || 0 }, + bestTrendData, + bestTrendSummary: util.getTrendSummary(bestTrendData, mode) + }) }, /** @@ -171,6 +195,10 @@ Page({ return util.getTrendData(storage.getRecords(), mode) }, + _computeBestTrend(mode) { + return util.getBestTrendData(storage.getRecords(), mode) + }, + onDayTap(e) { const { year, month, day } = e.detail const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` diff --git a/pages/records/records.wxml b/pages/records/records.wxml index 3c42ab3..b69b352 100644 --- a/pages/records/records.wxml +++ b/pages/records/records.wxml @@ -80,7 +80,14 @@ + + + + + + + diff --git a/pages/records/records.wxss b/pages/records/records.wxss index ad16d5a..8c8d1c4 100644 --- a/pages/records/records.wxss +++ b/pages/records/records.wxss @@ -199,6 +199,32 @@ opacity: 1; } +/* ---- records / trend top-level switch ---- */ +.records-content-tabs { + display: flex; + margin: 8rpx 0 20rpx; + padding: 6rpx; + background: var(--bg-soft); + border-radius: 999rpx; +} + +.records-content-tab { + flex: 1; + padding: 14rpx 0; + border-radius: 999rpx; + color: var(--text-secondary); + font-size: 26rpx; + text-align: center; + transition: background 0.25s ease, color 0.25s ease; +} + +.records-content-tab.active { + background: var(--card-bg); + color: var(--primary); + font-weight: 700; + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.10); +} + /* ---- calendar ---- */ .calendar-section { padding: 0; @@ -267,6 +293,79 @@ margin-bottom: 0; } +.trend-summary { + display: flex; + margin: 16rpx 0 4rpx; + padding: 16rpx 0; + background: rgba(var(--primary-rgb), 0.06); + border-radius: 16rpx; +} + +.trend-summary-item { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + min-width: 0; +} + +.trend-summary-value { + color: var(--primary); + font-size: 28rpx; + font-weight: 800; +} + +.trend-summary-label { + margin-top: 4rpx; + color: var(--text-secondary); + font-size: 20rpx; +} + +.trend-highlight { + display: flex; + align-items: center; + gap: 8rpx; + margin-top: 18rpx; + padding: 14rpx 16rpx; + background: var(--bg-soft); + border-radius: 14rpx; +} + +.trend-highlight-icon { + width: 28rpx; + height: 28rpx; +} + +.trend-highlight-title { + color: var(--text-secondary); + font-size: 22rpx; +} + +.trend-highlight-text { + color: var(--text); + font-size: 22rpx; + font-weight: 600; +} + +.best-trend-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 28rpx; +} + +.best-trend-title { + color: var(--text); + font-size: 26rpx; + font-weight: 700; +} + +.best-trend-best { + color: var(--primary); + font-size: 20rpx; + font-weight: 600; +} + /* ---- history ---- */ .history-section { padding: 0; diff --git a/tests/trend-state.test.js b/tests/trend-state.test.js index 48dfb23..2768ef8 100644 --- a/tests/trend-state.test.js +++ b/tests/trend-state.test.js @@ -1,7 +1,7 @@ const test = require('node:test') const assert = require('node:assert/strict') -const { getTrendData, getBarPercent } = require('../utils/util') +const { getTrendData, getBestTrendData, getTrendSummary, getBarPercent } = require('../utils/util') test('weekly trend combines multiple sessions from the same day', () => { const records = { @@ -45,3 +45,34 @@ test('bar height remains proportional for very short sessions', () => { assert.ok(getBarPercent(1, 120) < 1) assert.equal(getBarPercent(120, 120), 100) }) + +test('trend summary reports total, active periods, and the highest period', () => { + const summary = getTrendSummary([ + { label: '一', value: 60 }, + { label: '二', value: 0 }, + { label: '今天', value: 75 } + ], 'week') + + assert.deepEqual(summary, { + totalText: '02:15', + activeCount: 2, + activeLabel: '训练天数', + peakTitle: '最高训练日', + peakText: '今天 · 01:15' + }) +}) + +test('personal best trend keeps the longest single session in each period', () => { + 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 } + ] + } + + const data = getBestTrendData(records, 'week', new Date(2026, 6, 29)) + assert.equal(data[0].value, 60) + assert.equal(data[6].value, 45) + assert.equal(data[6].valueText, '00:45') +}) diff --git a/utils/util.js b/utils/util.js index dc8fed5..4cbee47 100644 --- a/utils/util.js +++ b/utils/util.js @@ -115,12 +115,65 @@ const getTrendData = (recordGroups, mode, now = new Date()) => { }) } +const getBestTrendData = (recordGroups, mode, now = new Date()) => { + const peaks = {} + 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 + const key = mode === 'month' ? date.substring(0, 7) : date + peaks[key] = Math.max(peaks[key] || 0, _normalizeDuration(record.duration)) + }) + }) + + const today = new Date(now) + const weekdays = ['日', '一', '二', '三', '四', '五', '六'] + const pad = (n) => String(n).padStart(2, '0') + const count = mode === 'month' ? 6 : 7 + return Array.from({ length: count }, (_, index) => { + const offset = count - 1 - index + const date = mode === 'month' + ? new Date(today.getFullYear(), today.getMonth() - offset, 1) + : new Date(today.getFullYear(), today.getMonth(), today.getDate() - offset) + const key = mode === 'month' + ? `${date.getFullYear()}-${pad(date.getMonth() + 1)}` + : `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + const value = peaks[key] || 0 + return { + label: mode === 'month' ? `${date.getMonth() + 1}月` : 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 } +const getTrendSummary = (trendData, mode) => { + const items = Array.isArray(trendData) ? trendData : [] + const total = items.reduce((sum, item) => sum + _normalizeDuration(item && item.value), 0) + const activeItems = items.filter(item => _normalizeDuration(item && item.value) > 0) + const peak = activeItems.reduce((current, item) => { + return !current || _normalizeDuration(item.value) > _normalizeDuration(current.value) ? item : current + }, null) + const isMonth = mode === 'month' + + return { + totalText: _formatTrendDuration(total), + activeCount: activeItems.length, + activeLabel: isMonth ? '活跃月份' : '训练天数', + peakTitle: isMonth ? '最高训练月' : '最高训练日', + peakText: peak ? `${peak.label} · ${_formatTrendDuration(peak.value)}` : '暂无训练数据' + } +} + /** * Convert a local file path (typically `wxfile://...` from chooseAvatar) to a * base64 data URI. Used to make temporary file URLs survive the WeChat @@ -169,6 +222,8 @@ module.exports = { formatDate, dateOnly: _dateOnly, getTrendData, + getBestTrendData, + getTrendSummary, getBarPercent, fileToDataURI }