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)
This commit is contained in:
+124
-7
@@ -4,11 +4,48 @@ 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 getRecords = () => wx.getStorageSync(RECORDS_KEY) || {}
|
||||
/**
|
||||
* 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 = Date.now()
|
||||
record.id = String(Date.now())
|
||||
const records = getRecords()
|
||||
const month = record.date.substring(0, 7)
|
||||
if (!records[month]) records[month] = []
|
||||
@@ -19,12 +56,21 @@ const saveRecord = (record) => {
|
||||
}
|
||||
|
||||
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) => {
|
||||
records[month] = records[month].filter((r) => r.id !== recordId)
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -50,8 +96,6 @@ const getTotalStats = () => {
|
||||
|
||||
const getSettings = () => wx.getStorageSync(SETTINGS_KEY) || {
|
||||
planId: 'beginner',
|
||||
dailyReminder: true,
|
||||
reminderTime: '08:00',
|
||||
voiceGuide: true,
|
||||
vibrate: true
|
||||
}
|
||||
@@ -63,6 +107,75 @@ const saveSettings = (settings) => {
|
||||
|
||||
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()
|
||||
@@ -84,7 +197,8 @@ const updateStreak = (date) => {
|
||||
const getToday = () => formatDate(new Date())
|
||||
|
||||
const getDateOffset = (dateStr, offset) => {
|
||||
const d = new Date(dateStr)
|
||||
// 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)
|
||||
}
|
||||
@@ -113,10 +227,13 @@ module.exports = {
|
||||
getTotalStats,
|
||||
getSettings,
|
||||
saveSettings,
|
||||
getCustomPlans,
|
||||
saveCustomPlan,
|
||||
resetCustomPlan,
|
||||
getStreak,
|
||||
validateStreak,
|
||||
updateStreak,
|
||||
getToday,
|
||||
formatDate,
|
||||
getDateOffset,
|
||||
getTodayRecord
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user