const { formatDate, dateOnly } = require('./util') const cloud = require('./cloud') const RECORDS_KEY = 'training_records' const SETTINGS_KEY = 'user_settings' const STREAK_KEY = 'current_streak' const CUSTOM_PLANS_KEY = 'custom_plans' const PROFILE_KEY = 'user_profile' let _idSeq = 0 /** * Backfill an `id` for any record that doesn't have one. * Older records (e.g. data restored from cloud or migrated from a build * that didn't yet write the id field) come in without an id, which makes * delete-by-id fail with "记录数据异常". Assign a stable, deterministic id * derived from the record's position + payload so the user can still * delete them. * * Returns true if any record was patched. */ const _ensureRecordIds = (records) => { if (!records || typeof records !== 'object') return false let changed = false Object.keys(records).forEach((month) => { const list = records[month] if (!Array.isArray(list)) return list.forEach((r, i) => { if (r && (r.id == null || r.id === '')) { // month is YYYY-MM; strip the dash so the id stays a safe token const m = String(month).replace(/[^0-9]/g, '') r.id = `legacy-${m}-${i}-${r.duration || 0}` changed = true } }) }) return changed } const getRecords = () => { const records = wx.getStorageSync(RECORDS_KEY) || {} if (_ensureRecordIds(records)) { // Persist the backfill so subsequent calls don't re-mutate; the cloud // will resync on the next saveRecord/deleteRecord. try { wx.setStorageSync(RECORDS_KEY, records) } catch (e) {} } return records } const saveRecord = (record) => { record.id = String(Date.now()) + '_' + (++_idSeq) const records = getRecords() const month = record.date.substring(0, 7) if (!records[month]) records[month] = [] records[month].push(record) records[month].sort((a, b) => b.date.localeCompare(a.date)) wx.setStorageSync(RECORDS_KEY, records) console.log('[storage] saveRecord date=' + record.date + ' duration=' + record.duration) cloud.pushAll() } const deleteRecord = (recordId) => { if (!recordId && recordId !== 0) return // guard against undefined/null const id = String(recordId) if (!id || id === 'undefined' || id === 'null') return const records = getRecords() let found = false Object.keys(records).forEach((month) => { const before = records[month].length records[month] = records[month].filter((r) => String(r.id) !== id) if (records[month].length < before) found = true if (records[month].length === 0) delete records[month] }) if (!found) return // id not found, don't overwrite storage wx.setStorageSync(RECORDS_KEY, records) // Re-validate streak: deleting the last record for a day should break the streak validateStreak() cloud.pushAll() } const getRecordsByMonth = (month) => { const records = getRecords() return records[month] || [] } const getTotalStats = () => { const records = getRecords() let totalDuration = 0 let totalSessions = 0 let maxDuration = 0 Object.keys(records).forEach((key) => { records[key].forEach((r) => { totalDuration += r.duration totalSessions++ if (r.duration > maxDuration) maxDuration = r.duration }) }) return { totalDuration, totalSessions, maxDuration } } const getSettings = () => wx.getStorageSync(SETTINGS_KEY) || { planId: 'beginner', voiceGuide: true, vibrate: true } const saveSettings = (settings) => { wx.setStorageSync(SETTINGS_KEY, settings) cloud.pushAll() } /** * User profile (nickname + avatar). Stored locally and pushed to the * cloud alongside records/settings so the leaderboard can show real * names instead of masked openids. * * Shape: { nickname: string, avatarUrl: string (wxfile://...) } * Both fields are optional; absent profile returns `{}`. */ const getProfile = () => { const raw = wx.getStorageSync(PROFILE_KEY) if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw return {} } const saveProfile = (profile) => { // Strip empties so we don't push {nickname: '', avatarUrl: ''} const clean = {} if (profile && typeof profile.nickname === 'string' && profile.nickname.trim()) { clean.nickname = profile.nickname.trim().slice(0, 16) } if (profile && typeof profile.avatarUrl === 'string' && profile.avatarUrl) { clean.avatarUrl = profile.avatarUrl } wx.setStorageSync(PROFILE_KEY, clean) cloud.pushAll() return clean } const getStreak = () => wx.getStorageSync(STREAK_KEY) || { count: 0, lastDate: '' } /** * User-customized training plans, keyed by planId (e.g. 'beginner'). * Each value is a full plan object built by `utils/plan.generatePlanDays` * plus the editor's formula fields (startTarget / increment / cycleDays). * * Storage shape mirrors `getRecords()`: an object map so partial writes * don't have to read+modify+write the whole collection. */ const getCustomPlans = () => { const raw = wx.getStorageSync(CUSTOM_PLANS_KEY) if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw return {} } const saveCustomPlan = (planId, planData) => { if (!planId) return const plans = getCustomPlans() plans[planId] = planData wx.setStorageSync(CUSTOM_PLANS_KEY, plans) cloud.pushAll() } const resetCustomPlan = (planId) => { if (!planId) return const plans = getCustomPlans() if (!(planId in plans)) return delete plans[planId] wx.setStorageSync(CUSTOM_PLANS_KEY, plans) cloud.pushAll() } /** * Validate streak against actual training records. * Call on app launch to correct any inconsistencies * (e.g. after deleting the only record for a day). * * Streak compares date-ONLY portions of `lastDate` so that training * twice on the same day (with different seconds in `record.date`) * doesn't accidentally reset the streak. */ const validateStreak = () => { const streak = getStreak() if (!streak.lastDate) return streak const today = getToday() const todayOnly = dateOnly(today) const yesterdayOnly = dateOnly(getDateOffset(today, -1)) const records = getRecords() const allDates = new Set() Object.values(records).forEach(monthRecs => { monthRecs.forEach(r => allDates.add(dateOnly(r.date))) }) const lastOnly = dateOnly(streak.lastDate) // If lastDate is today, check today still has records if (lastOnly === todayOnly) { if (!allDates.has(todayOnly)) { // Today's records were deleted if (allDates.has(yesterdayOnly)) { streak.lastDate = yesterdayOnly streak.count = Math.max(1, streak.count - 1) } else { streak.count = 0 streak.lastDate = '' } wx.setStorageSync(STREAK_KEY, streak) } return streak } // If lastDate points to a date that no longer has ANY records, // recompute the streak from actual recorded dates. if (!allDates.has(lastOnly)) { const sorted = Array.from(allDates).sort().reverse() if (sorted.length === 0) { streak.count = 0 streak.lastDate = '' } else { let count = 1 let prev = sorted[0] for (let i = 1; i < sorted.length; i++) { const prevDate = new Date(prev.replace(/-/g, '/')) const curDate = new Date(sorted[i].replace(/-/g, '/')) const diff = Math.round((prevDate - curDate) / (1000 * 60 * 60 * 24)) if (diff === 1) { count++ prev = sorted[i] } else { break } } streak.count = count streak.lastDate = sorted[0] } wx.setStorageSync(STREAK_KEY, streak) } return streak } const updateStreak = (date) => { const streak = getStreak() const todayFull = date || getToday() const todayOnly = dateOnly(todayFull) const yesterdayOnly = dateOnly(getDateOffset(todayFull, -1)) if (dateOnly(streak.lastDate) === todayOnly) return streak if (dateOnly(streak.lastDate) === yesterdayOnly) { streak.count += 1 } else { streak.count = 1 } // Store the date-ONLY portion so subsequent comparisons are stable // across multiple same-day trainings with different seconds. streak.lastDate = todayOnly wx.setStorageSync(STREAK_KEY, streak) cloud.pushAll() return streak } const getToday = () => formatDate(new Date()) const getDateOffset = (dateStr, offset) => { // Strip the time portion (formatDate now includes HH:MM:SS) and convert // dashes to slashes — `new Date("YYYY/MM/DD")` is the only reliably- // parsed form across both V8 (WeChat dev tools) and JSCore (iOS). const d = new Date(dateOnly(dateStr).replace(/-/g, '/')) d.setDate(d.getDate() + offset) return formatDate(d) } const getTodayRecord = () => { const today = getToday() // Record `date` is "YYYY-MM-DD HH:MM:SS" (formatted at saveRecord time), // so a strict === check against the freshly-formatted `today` only matches // a record saved in the exact same second. Compare date-only portions // instead, otherwise the second training of the day reads as zero and // the home page flips "今日已完成" back off. const todayOnly = dateOnly(today) const records = getRecords() let totalDuration = 0 let hasRecord = false Object.keys(records).forEach((month) => { records[month].forEach((r) => { if (dateOnly(r.date) === todayOnly) { totalDuration += r.duration hasRecord = true } }) }) return hasRecord ? { date: today, duration: totalDuration } : null } module.exports = { getRecords, saveRecord, deleteRecord, getRecordsByMonth, getTotalStats, getSettings, saveSettings, getCustomPlans, saveCustomPlan, resetCustomPlan, getProfile, saveProfile, getStreak, validateStreak, updateStreak, getToday, getDateOffset, getTodayRecord }