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:
2026-06-10 17:23:13 +08:00
parent 90e0e64156
commit 1d55df72b3
54 changed files with 2710 additions and 934 deletions
+50 -21
View File
@@ -1,20 +1,22 @@
const DB_COLLECTION = 'plank_data'
const ENV_ID = 'cloudbase-d1g56kl2q8f4f7d8a'
let _db = null
let _pushTimer = null
let _enabled = false
let _docId = null // cache doc id after first successful query
const getDb = () => {
if (!_enabled) return null
if (!_db) {
try { _db = wx.cloud.database() } catch (e) { _enabled = false }
try { _db = wx.cloud.database({ env: ENV_ID }) } catch (e) { _enabled = false }
}
return _db
}
const init = () => {
try {
wx.cloud.init({ env: 'cloudbase-d1g56kl2q8f4f7d8a', traceUser: true })
wx.cloud.init({ env: ENV_ID, traceUser: true })
_enabled = true
} catch (e) {
_enabled = false
@@ -25,10 +27,22 @@ const pullAll = async () => {
const db = getDb()
if (!db) return null
try {
const res = await db.collection(DB_COLLECTION)
.where({ _openid: '{openid}' })
.get()
return (res && res.data && res.data.length > 0) ? res.data[0] : null
// First try cached doc id
if (_docId) {
try {
const res = await db.collection(DB_COLLECTION).doc(_docId).get()
if (res && res.data) return res.data
} catch (e) {
_docId = null // doc deleted or inaccessible
}
}
// Query: rely on security rules to scope to current user
const res = await db.collection(DB_COLLECTION).limit(1).get()
if (res && res.data && res.data.length > 0) {
_docId = res.data[0]._id
return res.data[0]
}
return null
} catch (e) {
return null
}
@@ -51,20 +65,29 @@ const _doPush = async () => {
records: storage.getRecords(),
settings: storage.getSettings(),
streak: storage.getStreak(),
customPlans: storage.getCustomPlans(),
themeId: themeMod.getCurrentTheme().id,
updatedAt: db.serverDate()
}
// Query then upsert: one doc per user
const existing = await db.collection(DB_COLLECTION)
.where({ _openid: '{openid}' })
.get()
// Use cached doc id if available
if (_docId) {
try {
await db.collection(DB_COLLECTION).doc(_docId).update({ data })
return
} catch (e) {
_docId = null // doc may have been deleted
}
}
// Query for existing doc (relies on security rules to scope to current user)
const existing = await db.collection(DB_COLLECTION).limit(1).get()
if (existing && existing.data && existing.data.length > 0) {
await db.collection(DB_COLLECTION)
.doc(existing.data[0]._id)
.update({ data })
_docId = existing.data[0]._id
await db.collection(DB_COLLECTION).doc(_docId).update({ data })
} else {
await db.collection(DB_COLLECTION).add({ data })
const res = await db.collection(DB_COLLECTION).add({ data })
if (res && res._id) _docId = res._id
}
} catch (e) {
// Silently retry on next write
@@ -75,14 +98,20 @@ const clearAll = async () => {
const db = getDb()
if (!db) return
try {
const existing = await db.collection(DB_COLLECTION)
.where({ _openid: '{openid}' })
.get()
if (existing && existing.data && existing.data.length > 0) {
await db.collection(DB_COLLECTION)
.doc(existing.data[0]._id)
.remove()
if (_docId) {
try {
await db.collection(DB_COLLECTION).doc(_docId).remove()
_docId = null
return
} catch (e) {
_docId = null
}
}
const existing = await db.collection(DB_COLLECTION).limit(1).get()
if (existing && existing.data && existing.data.length > 0) {
await db.collection(DB_COLLECTION).doc(existing.data[0]._id).remove()
}
_docId = null
} catch (e) {
// Nothing to clear
}
+136
View File
@@ -0,0 +1,136 @@
/**
* Inline SVG icon library for the plank training app.
*
* Each icon is a 24x24 Material-style path. We bake the color directly into
* the SVG data URI, so consumers can switch between "active" and "inactive"
* variants by simply selecting a different key — no font loading, no CSS
* pseudo-element tricks, and exact pixel rendering at any size.
*
* Usage:
* const icons = require('../../utils/icons')
* const map = icons.build({ primary: '#FF6B35', primaryLight: '#FF8C5A', success: '#4CAF50' })
* // map.playFill => "data:image/svg+xml;..." (orange play)
* // map.play => "data:image/svg+xml;..." (gray play)
*
* Naming convention:
* <name> = outlined / inactive (neutral gray)
* <name>Fill = filled / active (theme color)
*/
const PATHS = {
// Navigation & UI
home: '<path d="M12 5.2L5.5 11H7v8h2v-6h6v6h2v-8h1.5L12 5.2M12 3l9 8h-3v9h-5v-6h-2v6H6v-9H3l9-8z" fill-rule="evenodd"/>',
homeFill: '<path d="M12 3l9 8h-3v9h-5v-6h-2v6H6v-9H3l9-8z"/>',
calendar: '<path d="M19 4h-2V2h-2v2H9V2H7v2H5C3.9 4 3 4.9 3 6v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2z" fill-rule="evenodd"/>',
calendarFill: '<path d="M19 4h-2V2h-2v2H9V2H7v2H5C3.9 4 3 4.9 3 6v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2zM7 12h5v5H7z"/>',
settings: '<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" fill-rule="evenodd"/>',
settingsFill: '<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/>',
back: '<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>',
right: '<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>',
close: '<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>',
check: '<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>',
// Status & feedback
hot: '<path d="M13.5.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z" fill-rule="evenodd"/>',
hotFill: '<path d="M13.5.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"/>',
like: '<path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z" fill-rule="evenodd"/>',
likeFill: '<path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/>',
// Training specific
play: '<path d="M8 5v14l11-7z" fill-rule="evenodd"/>',
playFill: '<path d="M8 5v14l11-7z"/>',
stop: '<path d="M6 6h12v12H6z" fill-rule="evenodd"/>',
stopFill: '<path d="M6 6h12v12H6z"/>',
pause: '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" fill-rule="evenodd"/>',
pauseFill: '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>',
time: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" fill-rule="evenodd"/>',
timeFill: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/>',
// Markers & targets
target: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" fill-rule="evenodd"/><circle cx="12" cy="12" r="5" fill-rule="evenodd"/><circle cx="12" cy="12" r="2"/>',
targetFill: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm0-14c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>',
mark: '<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 010-5 2.5 2.5 0 010 5z" fill-rule="evenodd"/>',
markFill: '<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 010-5 2.5 2.5 0 010 5z"/>',
// Stats & charts
rank: '<path d="M3.5 18.49l6-6.01 4 4L22 6.92l-1.41-1.41-7.09 7.97-4-4L2 16.99z" fill-rule="evenodd"/>',
rankFill: '<path d="M3.5 18.49l6-6.01 4 4L22 6.92l-1.41-1.41-7.09 7.97-4-4L2 16.99z"/>',
crown: '<path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5zm0 2h14v2H5z" fill-rule="evenodd"/>',
crownFill: '<path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5zm0 2h14v2H5z"/>',
form: '<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" fill-rule="evenodd"/>',
formFill: '<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/>',
// Notification
notification: '<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" fill-rule="evenodd"/>',
notificationFill: '<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>',
notificationForbid: '<path d="M20 18.69L7.84 6.14 5.27 3.49 4 4.76l2.8 2.8v.01c-.52.99-.8 2.16-.8 3.43v5l-2 2v1h13.69l2 2L21 19.72l-1-1.03zM12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6.69V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68c-.15.03-.29.07-.43.11-.07.02-.14.04-.2.06-.15.05-.29.1-.43.16-.06.02-.12.05-.18.08-.14.06-.28.13-.41.2-.05.03-.1.05-.15.08L18 14.31z" fill-rule="evenodd"/>',
notificationForbidFill: '<path d="M20 18.69L7.84 6.14 5.27 3.49 4 4.76l2.8 2.8v.01c-.52.99-.8 2.16-.8 3.43v5l-2 2v1h13.69l2 2L21 19.72l-1-1.03zM12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6.69V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68c-.15.03-.29.07-.43.11-.07.02-.14.04-.2.06-.15.05-.29.1-.43.16-.06.02-.12.05-.18.08-.14.06-.28.13-.41.2-.05.03-.1.05-.15.08L18 14.31z"/>',
// People & users
people: '<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" fill-rule="evenodd"/>',
peopleFill: '<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/>',
// Actions
delete: '<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" fill-rule="evenodd"/>',
deleteFill: '<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>',
info: '<path d="M11 17h2v-6h-2v6zm1-15C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM11 9h2V7h-2v2z" fill-rule="evenodd"/>',
infoFill: '<path d="M11 17h2v-6h-2v6zm1-15C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM11 9h2V7h-2v2z"/>',
// Theme & preferences
skin: '<path d="M12 2C6.49 2 2 6.49 2 12s4.49 10 10 10c1.38 0 2.5-1.12 2.5-2.5 0-.61-.23-1.2-.64-1.67-.08-.1-.13-.21-.13-.33 0-.28.22-.5.5-.5H16c3.31 0 6-2.69 6-6 0-4.96-4.49-9-10-9zm5.5 11c-.83 0-1.5-.67-1.5-1.5S16.67 10 17.5 10s1.5.67 1.5 1.5S18.33 13 17.5 13zm-3-4c-.83 0-1.5-.67-1.5-1.5S13.67 7 14.5 7s1.5.67 1.5 1.5S15.33 9 14.5 9zM9 12c-.83 0-1.5-.67-1.5-1.5S8.17 9 9 9s1.5.67 1.5 1.5S9.83 12 9 12z" fill-rule="evenodd"/>',
skinFill: '<path d="M12 2C6.49 2 2 6.49 2 12s4.49 10 10 10c1.38 0 2.5-1.12 2.5-2.5 0-.61-.23-1.2-.64-1.67-.08-.1-.13-.21-.13-.33 0-.28.22-.5.5-.5H16c3.31 0 6-2.69 6-6 0-4.96-4.49-9-10-9zm5.5 11c-.83 0-1.5-.67-1.5-1.5S16.67 10 17.5 10s1.5.67 1.5 1.5S18.33 13 17.5 13zm-3-4c-.83 0-1.5-.67-1.5-1.5S13.67 7 14.5 7s1.5.67 1.5 1.5S15.33 9 14.5 9zM9 12c-.83 0-1.5-.67-1.5-1.5S8.17 9 9 9s1.5.67 1.5 1.5S9.83 12 9 12z"/>',
// Lightbulb & tip
light: '<path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7z" fill-rule="evenodd"/>',
lightFill: '<path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7z"/>',
// Voice
voice: '<path d="M12 15c1.66 0 2.99-1.34 2.99-3L15 6c0-1.66-1.34-3-3-3S9 4.34 9 6v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 15 6.7 12H5c0 3.41 2.72 6.23 6 6.72V22h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z" fill-rule="evenodd"/>',
voiceFill: '<path d="M12 15c1.66 0 2.99-1.34 2.99-3L15 6c0-1.66-1.34-3-3-3S9 4.34 9 6v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 15 6.7 12H5c0 3.41 2.72 6.23 6 6.72V22h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/>',
// Round check
roundCheck: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill-rule="evenodd"/>',
roundCheckFill: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>',
// Emoji / face
emoji: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" fill-rule="evenodd"/>',
emojiFill: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z"/>',
// Edit (pencil)
edit: '<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" fill-rule="evenodd"/>',
editFill: '<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>'
}
// NOTE: must encodeURIComponent the SVG so that the `#` in colors like
// `#FF6B35` doesn't get parsed as a fragment-identifier delimiter — that
// would silently truncate the SVG and the image would fail to render.
const _svg = (path, color) => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="' +
color + '">' + path + '</svg>'
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
}
const INACTIVE = '#999999'
const DANGER = '#E53935'
/**
* Build an icon map keyed by name. Two variants per name:
* <name> = inactive (gray)
* <name>Fill = active (theme primary)
* Plus semantic aliases (delete, etc.) with appropriate colors.
*
* @param {object} theme - { primary, primaryLight, success, danger }
* @returns {object} { <iconName>: dataUri, ... }
*/
const build = (theme) => {
const t = theme || {}
const p = t.primary || '#FF6B35'
const s = t.success || '#4CAF50'
const d = t.danger || DANGER
const out = {}
Object.keys(PATHS).forEach((name) => {
if (name.endsWith('Fill')) {
const base = name.slice(0, -4)
out[base] = _svg(PATHS[name], INACTIVE)
out[name] = _svg(PATHS[name], p)
} else {
out[name] = _svg(PATHS[name], INACTIVE)
}
})
// Semantic overrides
out.deleteActive = _svg(PATHS.deleteFill, d)
out.deleteInactive = _svg(PATHS.delete, INACTIVE)
out.successFill = _svg(PATHS.roundCheckFill, s)
out.successLine = _svg(PATHS.roundCheck, INACTIVE)
return out
}
module.exports = { build, PATHS, INACTIVE }
+76 -26
View File
@@ -1,36 +1,77 @@
const { formatDate } = require('./util')
/**
* Build the per-day target array for a plan from its formula fields.
*
* target(day) = startTarget + floor((day - 1) / cycleDays) * increment
*
* Examples:
* { totalDays: 7, startTarget: 30, increment: 10, cycleDays: 1 }
* → 30, 40, 50, 60, 70, 80, 90 (preset: 初级)
* { totalDays: 14, startTarget: 60, increment: 15, cycleDays: 2 }
* → 60, 60, 75, 75, 90, 90, ... (preset: 中级)
* { totalDays: 30, startTarget: 90, increment: 15, cycleDays: 3 }
* → 90, 90, 90, 105, 105, 105, ... (preset: 高级)
*/
const generatePlanDays = ({ totalDays, startTarget, increment, cycleDays }) => {
const n = Math.max(0, Math.floor(totalDays) || 0)
const start = Math.max(0, Math.floor(startTarget) || 0)
const inc = Math.max(0, Math.floor(increment) || 0)
const cycle = Math.max(1, Math.floor(cycleDays) || 1)
const days = []
for (let i = 0; i < n; i++) {
const d = i + 1
days.push({ day: d, target: start + Math.floor((d - 1) / cycle) * inc })
}
return days
}
const _describe = (plan) =>
`${plan.totalDays}天计划 · ${plan.startTarget}秒起步`
const _buildPlan = (base) => {
const plan = { ...base }
plan.days = generatePlanDays(plan)
plan.description = _describe(plan)
return plan
}
const plans = {
beginner: {
beginner: _buildPlan({
id: 'beginner',
name: '初级 (7天)',
totalDays: 7,
description: '适合初学者,从30秒起步',
days: Array.from({ length: 7 }, (_, i) => ({ day: i + 1, target: 30 + i * 10 }))
},
intermediate: {
startTarget: 30,
increment: 10,
cycleDays: 1
}),
intermediate: _buildPlan({
id: 'intermediate',
name: '中级 (14天)',
totalDays: 14,
description: '有一定基础,从60秒起步',
days: Array.from({ length: 14 }, (_, i) => {
const pair = Math.floor(i / 2)
return { day: i + 1, target: 60 + pair * 15 }
})
},
advanced: {
startTarget: 60,
increment: 15,
cycleDays: 2
}),
advanced: _buildPlan({
id: 'advanced',
name: '高级 (30天)',
totalDays: 30,
description: '挑战自我,从90秒起步',
days: Array.from({ length: 30 }, (_, i) => ({
day: i + 1,
target: 90 + Math.floor(i / 3) * 15
}))
}
startTarget: 90,
increment: 15,
cycleDays: 3
})
}
const getPlan = (planId) => plans[planId] || plans.beginner
/**
* Look up a plan by id. User-customized plans (passed in `customPlans`)
* shadow the preset with the same id, so e.g. editing "beginner" replaces
* the preset beginner for the user without changing the planId.
*/
const getPlan = (planId, customPlans) => {
if (customPlans && customPlans[planId]) return customPlans[planId]
return plans[planId] || plans.beginner
}
const getTodayTarget = (planId, currentDay) => {
const plan = getPlan(planId)
@@ -38,15 +79,24 @@ const getTodayTarget = (planId, currentDay) => {
return day ? day.target : plan.days[0].target
}
const getPlanDay = (planId, records) => {
/**
* Calculate the current plan day based on training records since plan start.
* Only counts records from the current plan's start date onward.
*/
const getPlanDay = (planId, records, planStartDate) => {
const plan = getPlan(planId)
const today = formatDate(new Date())
const trainedDays = new Set(
records
.filter(r => r.date !== today && r.planId === planId)
.map(r => r.date)
).size
// Filter records: same plan, after plan start date, and not today
const eligibleRecords = records.filter(r => {
if (r.planId !== planId) return false
if (r.date === today) return false
if (planStartDate && r.date < planStartDate) return false
return true
})
const trainedDays = new Set(eligibleRecords.map(r => r.date)).size
return Math.min(trainedDays + 1, plan.totalDays)
}
module.exports = { plans, getPlan, getTodayTarget, getPlanDay }
module.exports = { plans, generatePlanDays, getPlan, getTodayTarget, getPlanDay }
+124 -7
View File
@@ -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
}
+11 -9
View File
@@ -94,20 +94,22 @@ const setTheme = (id) => {
return theme
}
const applyThemeToPage = (self) => {
const theme = getCurrentTheme()
self.setData({
theme,
themeStyle: `${BASE_VARS}--primary:${theme.primary};--primary-light:${theme.primaryLight};--primary-bg:${theme.primaryBg};--primary-rgb:${theme.primaryRgb};`
})
_setNavBarColor(theme.primary)
}
const getThemeStyle = (theme) => {
const t = theme || getCurrentTheme()
return `${BASE_VARS}--primary:${t.primary};--primary-light:${t.primaryLight};--primary-bg:${t.primaryBg};--primary-rgb:${t.primaryRgb};`
}
const applyThemeToPage = (self) => {
const theme = getCurrentTheme()
const nextStyle = getThemeStyle(theme)
// Skip setData if nothing changed — avoid unnecessary re-renders on every onShow
if (self.data && self.data.themeStyle === nextStyle) {
return
}
self.setData({ theme, themeStyle: nextStyle })
_setNavBarColor(theme.primary)
}
module.exports = {
THEMES,
DEFAULT_THEME_ID,
+94
View File
@@ -0,0 +1,94 @@
/**
* Voice prompt utility for the timer.
*
* 4 fixed, polished prompts live in `cloudfunctions/tts`. We call that
* cloud function with a key, get back an audio URL (cached in cloud
* storage after first synthesis), and play it via createInnerAudioContext.
*
* The in-memory `_urlCache` avoids hitting the cloud function more than
* once per prompt per session. The audio context is a singleton so
* overlapping prompts interrupt each other cleanly.
*
* If the cloud function is unconfigured (no TTS credentials) and returns
* `success: false`, we fall back to a silent no-op — better than spamming
* the user with toast errors mid-plank.
*/
const PROMPTS = {
halfway: '已完成一半啦,坚持就是胜利!',
last30: '最后30秒,保持呼吸,稳住姿势!',
last10: '最后10秒,再加把劲!',
complete: '太棒了!今天的目标已完成,继续加油!'
}
let _ctx = null
const _urlCache = {} // { promptKey: audioUrl }
const _getCtx = () => {
if (!_ctx) {
_ctx = wx.createInnerAudioContext()
// Play even when the device is in silent mode — we want the user to
// actually hear training cues during a session.
_ctx.obeyMuteSwitch = false
}
return _ctx
}
const _fetchUrl = async (promptKey) => {
if (_urlCache[promptKey]) return _urlCache[promptKey]
try {
const res = await wx.cloud.callFunction({
name: 'tts',
data: { promptKey },
// Default WeChat cloud timeout is 3s, which is exactly the TTS
// first-call budget (synth + upload + tempURL). Bump to 30s for
// cold starts; cached calls return in <500ms.
config: { timeout: 30000 }
})
if (res && res.result && res.result.success && res.result.audioUrl) {
_urlCache[promptKey] = res.result.audioUrl
return res.result.audioUrl
}
} catch (e) {
// cloud function not deployed or other error — silent fallback
}
return null
}
/**
* Play a prompt. Returns a Promise that resolves when playback starts
* (or immediately if there's no audio available).
*/
const play = async (promptKey) => {
if (!PROMPTS[promptKey]) return
const url = await _fetchUrl(promptKey)
if (!url) return
const ctx = _getCtx()
try {
ctx.stop()
ctx.src = url
ctx.play()
} catch (e) { /* ignore playback errors */ }
}
/**
* Stop any currently-playing voice prompt. Call this when the user
* pauses/stops/finishes training to avoid stray audio.
*/
const stop = () => {
if (_ctx) {
try { _ctx.stop() } catch (e) { /* ignore */ }
}
}
/**
* Tear down the audio context. Call on page unload.
*/
const destroy = () => {
if (_ctx) {
try { _ctx.destroy() } catch (e) { /* ignore */ }
_ctx = null
}
}
module.exports = { play, stop, destroy, PROMPTS }