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 /** * Record the timestamp of the last local mutation to single-value fields * (settings / profile / customPlans / theme). _restoreFromCloud compares * this against the cloud doc's updatedAt: if local changed after the cloud * snapshot, we keep local to avoid clobbering unsynced edits. Records are * exempt - they go through mergeRecords() which never loses data. */ const _markLocalChanged = () => { try { wx.setStorageSync('_local_changed_at', Date.now()) } catch (e) {} } /** * 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) // Defensive: reject records with a future date. Indicates the user has // changed their phone clock, which would otherwise poison the cloud // leaderboard (and their own stats) with impossible timestamps. Allow // a 1-minute tolerance for normal clock skew. const recordTime = new Date(record.date.replace(/-/g, '/')).getTime() if (recordTime > Date.now() + 60 * 1000) { console.warn('[storage] saveRecord rejected: future date', record.date) wx.showToast({ title: '系统时间异常,请检查', icon: 'none' }) return } 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) // 重新计算连续打卡:删连续链中间某天会断链,validateStreak 只在 lastDate 被删时重算,不够 recomputeStreak() cloud.pushAll() } /** * Merge cloud records into local records WITHOUT losing either side. * * Why this exists: _restoreFromCloud used to overwrite training_records * with the cloud doc verbatim. If the user finished a training during the * async pullAll on launch, saveRecord had already written locally, and the * subsequent restore wiped that fresh record with the cloud's older * snapshot. Same hazard for multi-device edits. * * Dedup: records with a real (non-legacy) id dedup by id; legacy/idless * records dedup by `date|duration` so old migrated data doesn't double up. * Result is re-bucketed by month and sorted newest-first. */ const mergeRecords = (cloudRecords) => { const local = wx.getStorageSync(RECORDS_KEY) || {} const cloud = (cloudRecords && typeof cloudRecords === 'object') ? cloudRecords : {} const seen = new Set() const out = [] const push = (r) => { if (!r) return const id = r.id const isRealId = id != null && id !== '' && !String(id).startsWith('legacy-') const key = isRealId ? `id:${id}` : `c:${r.date}|${r.duration}` if (seen.has(key)) return seen.add(key) out.push(r) } Object.keys(local).forEach((m) => (local[m] || []).forEach(push)) Object.keys(cloud).forEach((m) => (cloud[m] || []).forEach(push)) const merged = {} out.forEach((r) => { const mo = (r.date && r.date.substring) ? r.date.substring(0, 7) : 'unknown' if (!merged[mo]) merged[mo] = [] merged[mo].push(r) }) Object.keys(merged).forEach((mo) => { merged[mo].sort((a, b) => (b.date || '').localeCompare(a.date || '')) }) _ensureRecordIds(merged) return merged } 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) => { const d = Number(r && r.duration) || 0 totalDuration += d totalSessions++ if (d > maxDuration) maxDuration = d }) }) return { totalDuration, totalSessions, maxDuration } } const getSettings = () => { const s = wx.getStorageSync(SETTINGS_KEY) if (s) { // Backfill voiceGender for existing users (added with the male/female // voice option). Defaults to female, the prior behavior. if (!s.voiceGender) s.voiceGender = 'female' // Backfill vibrate/voiceGuide for users whose settings predate these // toggles. Without this, s.vibrate is undefined and `!s.vibrate` in // the timer reads it as "off" - vibration silently no-ops until the // user manually toggles the switch (which writes a real boolean). if (s.vibrate == null) s.vibrate = true if (s.voiceGuide == null) s.voiceGuide = true if (!s.vibrateIntensity) s.vibrateIntensity = 'heavy' return s } return { planId: 'beginner', voiceGuide: true, vibrate: true, voiceGender: 'female', vibrateIntensity: 'heavy' } } const saveSettings = (settings) => { wx.setStorageSync(SETTINGS_KEY, settings) _markLocalChanged() 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) _markLocalChanged() 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) _markLocalChanged() 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) _markLocalChanged() 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 } /** * Fully recompute the streak from actual training records. Used after a * cloud restore/merge: the cloud doc's streak may be stale or belong to * another device, so we trust the merged records over the cloud value. */ const recomputeStreak = () => { const records = getRecords() const allDates = new Set() Object.values(records).forEach((monthRecs) => { monthRecs.forEach((r) => allDates.add(dateOnly(r.date))) }) const sorted = Array.from(allDates).sort().reverse() if (sorted.length === 0) { const empty = { count: 0, lastDate: '' } wx.setStorageSync(STREAK_KEY, empty) return empty } 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 } } const streak = { count, 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, mergeRecords, getRecordsByMonth, getTotalStats, getSettings, saveSettings, getCustomPlans, saveCustomPlan, resetCustomPlan, getProfile, saveProfile, getStreak, validateStreak, recomputeStreak, updateStreak, _markLocalChanged, getToday, getDateOffset, getTodayRecord }