const formatTime = (seconds) => { const m = Math.floor(seconds / 60) const s = seconds % 60 return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` } const formatDuration = (seconds) => { if (seconds < 60) return `${seconds}秒` const m = Math.floor(seconds / 60) const s = seconds % 60 return s > 0 ? `${m}分${s}秒` : `${m}分钟` } const getDaysInMonth = (year, month) => new Date(year, month, 0).getDate() const getMonthCalendar = (year, month) => { const firstDay = new Date(year, month - 1, 1) const daysInMonth = new Date(year, month, 0).getDate() const startDayOfWeek = firstDay.getDay() const weeks = [] let week = [null, null, null, null, null, null, null] for (let d = 1; d <= daysInMonth; d++) { const dow = (startDayOfWeek + d - 1) % 7 week[dow] = d if (dow === 6 || d === daysInMonth) { weeks.push(week.slice()) week = [null, null, null, null, null, null, null] } } return weeks } /** * Format a Date as `YYYY-MM-DD HH:MM:SS` in local time. * * Used as the canonical `record.date` string so each record has a * precise timestamp (e.g. "2026-06-11 14:30:45"). Use `_dateOnly()` * to strip back to YYYY-MM-DD for date-level comparisons (streak, * leaderboard day filter, etc.). */ const formatDate = (date) => { const y = date.getFullYear() const mo = String(date.getMonth() + 1).padStart(2, '0') const d = String(date.getDate()).padStart(2, '0') const h = String(date.getHours()).padStart(2, '0') const mi = String(date.getMinutes()).padStart(2, '0') const s = String(date.getSeconds()).padStart(2, '0') return `${y}-${mo}-${d} ${h}:${mi}:${s}` } /** 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 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 * expiration window (a few days) by embedding the bytes inline. * * Rejects if the file exceeds `maxBytes` (default 512KB) so a stray huge * image doesn't blow the 10MB total wx.storage budget. Caller should * fall back to the raw URL on rejection. */ const fileToDataURI = (filePath, maxBytes = 512 * 1024) => { return new Promise((resolve, reject) => { if (!filePath || typeof wx.getFileSystemManager !== 'function') { reject(new Error('FileSystemManager unavailable')) return } const fs = wx.getFileSystemManager() fs.getFileInfo({ filePath, success: (info) => { if (info.size > maxBytes) { reject(new Error(`File too large: ${info.size} > ${maxBytes}`)) return } fs.readFile({ filePath, encoding: 'base64', success: (res) => { // chooseAvatar returns PNG; allow JPG/GIF/WEBP fallbacks. const m = ((filePath.match(/\.(\w+)(?:\?|$)/) || [])[1] || 'png').toLowerCase() const mime = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }[m] || 'image/png' resolve(`data:${mime};base64,${res.data}`) }, fail: reject }) }, fail: reject }) }) } module.exports = { formatTime, formatDuration, getDaysInMonth, getMonthCalendar, formatDate, dateOnly: _dateOnly, getTrendData, getBestTrendData, getTrendSummary, getBarPercent, fileToDataURI }