const { formatDate } = 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' /** * 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()) 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) 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() } 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). */ const validateStreak = () => { const streak = getStreak() if (!streak.lastDate) return streak const today = getToday() const yesterday = getDateOffset(today, -1) const records = getRecords() const allDates = new Set() Object.values(records).forEach(monthRecs => { monthRecs.forEach(r => allDates.add(r.date)) }) // If lastDate is today, check today still has records if (streak.lastDate === today) { if (!allDates.has(today)) { // Today's records were deleted if (allDates.has(yesterday)) { streak.lastDate = yesterday streak.count = Math.max(1, streak.count - 1) } else { streak.count = 0 streak.lastDate = '' } wx.setStorageSync(STREAK_KEY, streak) } return streak } // If lastDate is not today and not yesterday, streak is already broken // (updateStreak handles reset on next training), no action needed here return streak } const updateStreak = (date) => { const streak = getStreak() const today = date || getToday() const yesterday = getDateOffset(today, -1) if (streak.lastDate === today) return streak if (streak.lastDate === yesterday) { streak.count += 1 } else { streak.count = 1 } streak.lastDate = today wx.setStorageSync(STREAK_KEY, streak) cloud.pushAll() return streak } const getToday = () => formatDate(new Date()) const getDateOffset = (dateStr, offset) => { // Use YYYY/MM/DD for iOS compatibility (YYYY-MM-DD may fail on some iOS) const d = new Date(dateStr.replace(/-/g, '/')) d.setDate(d.getDate() + offset) return formatDate(d) } const getTodayRecord = () => { const today = getToday() const records = getRecords() let totalDuration = 0 let hasRecord = false Object.keys(records).forEach((month) => { records[month].forEach((r) => { if (r.date === today) { totalDuration += r.duration hasRecord = true } }) }) return hasRecord ? { date: today, duration: totalDuration } : null } module.exports = { getRecords, saveRecord, deleteRecord, getRecordsByMonth, getTotalStats, getSettings, saveSettings, getCustomPlans, saveCustomPlan, resetCustomPlan, getStreak, validateStreak, updateStreak, getToday, getDateOffset, getTodayRecord }