Files
wx_pbzc/utils/storage.js
T
lc 1d55df72b3 feat: v1.5 — plan editor, voice prompts, UI polish
Major features:
- Training plan editor: edit preset days/duration per plan (in-place
  override via custom_plans storage; preset ids preserved so existing
  records stay valid)
- Voice prompts at 4 fixed points during training (halfway / 30s /
  10s / done) via Tencent Cloud TTS (cloudfunctions/tts + utils/voice.js)
- Free-training button: switched from outline (transparent) to ghost
  variant (theme-tinted) for visual weight

Bug fixes:
- 4 functional pages: SVG data URIs failed to render in WeChat because
  '\#' in colors was parsed as a data-URI fragment delimiter. encode the
  whole SVG via encodeURIComponent in utils/icons.js build().
- Old records (saved before id field was added) could not be deleted
  (data-id was empty, triggered defensive guard). Backfill stable
  legacy-<month>-<index>-<duration> ids in getRecords() and persist.
- settings.js plan-row editor button event was bubbling up to the row's
  bindtap (which also fired onSelectPlan). Wrapped in catch:tap.

UI:
- Settings page: 3 hardcoded plans replaced with dynamic buildPlansList
  that overlays custom_plans on top of presets
- Plan editor: bottom-sheet modal in settings page (regular view, not
  ui-modal — WeChat custom component root element drops position:fixed
  in this runtime)
- Free-training button: rounded pill style, full-width, 24rpx gap from
  primary action; bottom sheet uses max-height: 88vh + internal scroll
- Version bumped to v1.5, last updated 2026-06-10

Removed:
- Daily reminder section (dailyReminder / reminderTime) — replaced by
  the 4 voice prompts which cover the same user need without requiring
  long-term scheduling that WeChat mini-programs can't actually do

Misc:
- utils/plan.js refactored to formula-driven: presets declare
  totalDays/startTarget/increment/cycleDays, days[] is generated. Same
  formula applies to custom plans.
- Timer _remind() guards each prompt on minimum duration so short
  free-mode sessions don't fire 'last30' at the start
- Cloud storage cloud_plans field added to data push payload; restored
  on first install via _restoreFromCloud
- .gitignore added for local AI tool caches (.reasonix/, reasonix.toml,
  .codegraph/daemon.pid)
2026-06-10 17:23:13 +08:00

240 lines
6.7 KiB
JavaScript

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
}