Skip to content

Commit 856238c

Browse files
authored
Merge pull request #105 from WardLu/codex/branch-residue-20260906
feat: complete minimal analytics and worksheet follow-ups
2 parents 9ef813c + b4b890c commit 856238c

18 files changed

Lines changed: 349 additions & 10 deletions

‎CHANGELOG.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@
22

33
## [Unreleased]
44

5-
_No unreleased changes._
5+
### Added
6+
- 页脚增加 `support@shadow.wang` 问题反馈入口。
7+
- 接入无自定义属性的最小产品事件,包括首次激活、首次打卡、连续三天打卡、学习记录同步失败和发音失败;同步更新中英文隐私说明。
8+
9+
### Fixed
10+
- 首次打卡事件排除撤销操作;连续打卡判断排除取消记录和无效日期。
11+
12+
### Tests
13+
- 补齐登录态字帖无“第一单元”断言,以及字帖轮换的同学习者合并、跨学习者隔离回归。
614

715
## [1.4.0] - 2026-09-05
816

‎PRIVACY.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
- 学习者显示名称和年级。建议使用昵称,不填写真实姓名。
1212
- 打卡、积分、书架和阅读日志等学习状态。
1313

14-
当前版本不要求儿童提供邮箱、手机号、生日、学校、地址、精确位置或照片,也不包含广告。应用通过 `@vercel/analytics` 使用 Vercel Web Analytics 记录匿名、聚合的页面访问数据;当前没有自定义事件,也不把学习状态、邮箱或儿童显示名称作为 Analytics 自定义字段发送。Vercel 文档说明页面访问数据可能包含时间、页面 URL、来源、设备/浏览器/操作系统和粗略地理位置;其设计不使用第三方 Cookie,也不跨站识别访客。使用 Vercel Analytics 的数据会发送到 Vercel 服务器,详情见 [Vercel Web Analytics Privacy and Compliance](https://vercel.com/docs/analytics/privacy-policy)。正式对外运营前仍需重新审查 URL、Referrer-Policy、家长同意和数据处理条款。
14+
当前版本不要求儿童提供邮箱、手机号、生日、学校、地址、精确位置或照片,也不包含广告。应用通过 `@vercel/analytics` 使用 Vercel Web Analytics 记录匿名、聚合的页面访问数据,以及以下不带自定义属性的最小事件:首次激活、首次打卡、达到连续 3 天打卡、用户可见的学习记录同步失败和用户可见的 TTS 失败。事件不会发送学习状态、邮箱、家庭/孩子 ID、儿童显示名称、错误文本或语音文本;首次和连续使用事件只在本机用标记去重。Vercel 文档说明页面访问和自定义事件可能包含时间、页面 URL、来源、设备/浏览器/操作系统和粗略地理位置;其设计不使用第三方 Cookie,也不跨站识别访客。使用 Vercel Analytics 的数据会发送到 Vercel 服务器,详情见 [Vercel Web Analytics Privacy and Compliance](https://vercel.com/docs/analytics/privacy-policy)。正式对外运营前仍需重新审查 URL、Referrer-Policy、家长同意和数据处理条款。
1515

1616
固定课程文字会在发布准备阶段发送给腾讯云语音合成,生成后的 MP3 保存在项目自己的 COS/CDN 中供所有用户复用。用户点击播放时不会把文字、学习记录、账号信息或儿童资料发送给腾讯云,也不会触发新的付费合成。浏览器可能按标准 HTTP 规则缓存这些音频。影伴不采集麦克风录音。
1717

‎THIRD_PARTY_NOTICES.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262

6363
| 文件或资源 | 来源和许可证 |
6464
| --- | --- |
65-
| `public/brand_assets/shadow-mate-writing-hand.ttf` | 项目负责人提供的“瑞美加张清平硬笔楷书”字体文件;许可证或公开分发授权信息未随仓库提供,公开发布前需补充确认并保留相应授权说明 |
65+
| `public/brand_assets/shadow-mate-writing-hand.ttf` | “瑞美加张清平硬笔楷书”;作者在 [CPC 中文印刷社区首发帖](https://www.cnprint.org/bbs/thread/342208/) 声明免费商用,允许商标和嵌入使用;禁止标价出售、改编再发布和换名发布。详见[授权资料](docs/licenses/ruimeijia-zhangqingping-font.md) |
6666

6767
## 资源指纹
6868

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# 瑞美加张清平硬笔楷书授权资料
2+
3+
## 作者声明来源
4+
5+
- 字体名称:瑞美加张清平硬笔楷书。
6+
- 来源:[CPC 中文印刷社区首发帖](https://www.cnprint.org/bbs/thread/342208/)。
7+
- 帖子标题:瑞美加张清平硬笔楷书免费商用CPC首发。
8+
- 声明位置:第 1 页、第 1 楼;页面显示发帖时间为 2022-04-13 19:44(页面未标明时区)。
9+
- 资料补充日期:2026-09-06。
10+
- 证据来源:项目负责人提供的原帖链接及截图,并确认该帖为作者的免费商用声明。本次网页读取未成功,以下原文依据所提供截图转录,不标为已实时回读原站。
11+
12+
截图中的授权段落原文:
13+
14+
> 上次发布 瑞美加张清平硬笔行书 受到诸多坛友鼓励支持。为表感谢,再上一款新款免费商用的楷体。禁止项:标价出售,改编再发布,换名发布等严重侵权行为之外,其它商用途都可以,比如 商标、嵌入都没问题的。
15+
16+
## 使用范围与限制
17+
18+
作者声明允许免费商用,并明确举例允许商标和嵌入使用。禁止标价出售、改编再发布、换名发布。该声明不是 MIT、OFL 或其他标准开源许可证,不应将其表述为无条件再分发或允许改编。
19+
20+
Shadow Mate 将此字体作为字帖的嵌入资源使用,保留原字体名称及作者来源说明,不将字体单独标价出售。本记录补充原有“授权资料未提供”的缺口,保留作者声明中的全部限制。
21+
22+
## 项目资源对应关系
23+
24+
| 项目 | 值 |
25+
| --- | --- |
26+
| 当前资源路径 | `public/brand_assets/shadow-mate-writing-hand.ttf` |
27+
| 项目负责人提供的原文件名 | `瑞美加张清平硬笔楷书.ttf` |
28+
| 两份文件的 SHA-256 | `38e6473bcbe4e3d2dba2218eb7235b4b5f7f3f080d75528f8e4d1fc8bc1c4a10` |
29+
30+
本次核对确认当前嵌入文件与项目负责人提供的原字体文件字节一致;没有修改字体数据。上述哈希绑定的是这两份本地文件,本次未重新下载论坛附件作哈希比对。

‎docs/release-closure.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,9 @@ Shadow Mate 的 v1.3.14 SEO/GEO 变更已合并并发布;随后 `production`
1818
- Mate 的登录后云同步如需补测,应使用专用测试家庭账号;Portal P0b 中的账号待办指 Portal 自身登录与 Dashboard 权限。CrUX 现场数据与长期性能趋势需持续观测。
1919

2020
产品 workflow 不携带生产数据库凭据,不直接执行 Supabase migration,也不写入生产用户数据。
21+
22+
## 旧 release-manifest 提案的处置(2026-09-06)
23+
24+
[Issue #103](https://github.com/WardLu/shadow-mate/issues/103) 中的旧 handoff 提案不再进入当前实现。旧 `release-closure.yml`、`release-manifest*.mjs` 及对应测试保持为历史材料,不迁入主线,也不增加 `release:manifest` 命令。旧 workflow 通过可变的跨仓库 `main` 调用 reusable workflow,不能作为发布状态的可靠依据,因此不应形成第二条交接路径。
25+
26+
该提案不会取代项目现有的发布审查。生产迁移、部署、正式发布和生产验收仍须通过各自独立门禁;本次整理不执行任何生产操作。

‎index.html‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ <h1>影伴 Shadow Mate</h1>
127127
<a class="site-footer-link" href="https://x.com/Gollumgulu" target="_blank" rel="noopener noreferrer" aria-label="打开 X">X</a>
128128
<span aria-hidden="true">·</span>
129129
<a class="site-footer-link" href="/privacy" target="_blank" rel="noopener noreferrer">隐私政策</a>
130+
<span aria-hidden="true">·</span>
131+
<a class="site-footer-link" href="mailto:support@shadow.wang?subject=Shadow%20Mate%20%E9%97%AE%E9%A2%98%E5%8F%8D%E9%A6%88" aria-label="发送问题反馈邮件至 support@shadow.wang">问题反馈</a>
130132
</nav>
131133
</footer>
132134
</div>

‎privacy-policy.html‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ <h2>我们收集和保存什么</h2>
6969
<p>当前版本不要求儿童提供邮箱、手机号、生日、学校、地址、精确位置或照片,也不包含广告。</p>
7070

7171
<h2>分析服务和朗读功能</h2>
72-
<p>应用通过 Vercel Web Analytics 记录匿名、聚合的页面访问数据;当前没有自定义事件,也不把学习状态、邮箱或儿童显示名称作为 Analytics 自定义字段发送。页面访问数据可能包含时间、页面 URL、来源、设备、浏览器、操作系统和粗略地理位置。详情见 <a href="https://vercel.com/docs/analytics/privacy-policy" target="_blank" rel="noopener noreferrer">Vercel Web Analytics Privacy and Compliance</a>。</p>
72+
<p>应用通过 Vercel Web Analytics 记录匿名、聚合的页面访问数据,以及以下不带自定义属性的最小事件:首次激活、首次打卡、达到连续 3 天打卡、用户可见的学习记录同步失败和用户可见的 TTS 失败。事件不会发送学习状态、邮箱、家庭/孩子 ID、儿童显示名称、错误文本或语音文本;首次和连续使用事件只在本机用标记去重。页面访问和自定义事件可能包含时间、页面 URL、来源、设备、浏览器、操作系统和粗略地理位置。详情见 <a href="https://vercel.com/docs/analytics/privacy-policy" target="_blank" rel="noopener noreferrer">Vercel Web Analytics Privacy and Compliance</a>。</p>
7373
<p>固定课程文字会在发布准备阶段发送给腾讯云语音合成,生成后的 MP3 保存在项目自己的 COS/CDN 中供所有用户复用。用户点击播放时不会把文字、学习记录、账号信息或儿童资料发送给腾讯云,也不会触发新的付费合成。浏览器可能按标准 HTTP 规则缓存这些音频。影伴不采集麦克风录音。</p>
7474

7575
<h2>家长同意和学习者档案</h2>
@@ -113,7 +113,7 @@ <h2>What We Collect and Store</h2>
113113
<p>The current version does not require a child to provide an email address, phone number, birthday, school, address, precise location, or photo. It does not contain advertising.</p>
114114

115115
<h2>Analytics and Reading Aloud</h2>
116-
<p>The app uses Vercel Web Analytics for anonymous, aggregated page-visit data. It currently sends no custom events and does not send learning status, email addresses, or learner display names as custom Analytics fields. Page-visit data may include the time, page URL, referrer, device, browser, operating system, and approximate location. See <a href="https://vercel.com/docs/analytics/privacy-policy" target="_blank" rel="noopener noreferrer">Vercel Web Analytics Privacy and Compliance</a> for details.</p>
116+
<p>The app uses Vercel Web Analytics for anonymous, aggregated page-visit data and a minimal set of custom events without custom properties: first activation, first check-in, reaching a three-day check-in streak, user-visible learning-record sync failures, and user-visible TTS failures. Events do not send learning state, email addresses, household/learner IDs, learner display names, error text, or speech text. First-use and streak events are deduplicated with local browser flags. Page views and custom events may include the time, page URL, referrer, device, browser, operating system, and approximate location. See <a href="https://vercel.com/docs/analytics/privacy-policy" target="_blank" rel="noopener noreferrer">Vercel Web Analytics Privacy and Compliance</a> for details.</p>
117117
<p>Fixed public curriculum text is sent to Tencent Cloud TTS during release preparation. Generated MP3 files are stored in the project's COS/CDN for shared reuse. Playback does not send curriculum text, learning history, account data, or child profile data to Tencent and does not trigger paid synthesis. Browsers may cache these files under standard HTTP rules. Shadow Mate does not record microphone audio.</p>
118118

119119
<h2>Parental Consent and Learner Profiles</h2>

‎src/analytics.js‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,13 @@ export function recordAnalyticsEvent(eventName, {
5555
export function hasConsecutiveCheckinDays(checkins = {}, minimum = 3) {
5656
if (!Number.isInteger(minimum) || minimum < 1) return false;
5757
const timestamps = Object.entries(checkins)
58-
.filter(([, value]) => value && typeof value === "object" && Object.keys(value).length > 0)
59-
.map(([date]) => Date.parse(`${date}T00:00:00Z`))
58+
.filter(([, value]) => value && typeof value === "object" && !Array.isArray(value) && Object.values(value).some(Boolean))
59+
.map(([date]) => {
60+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return NaN;
61+
const timestamp = Date.parse(`${date}T00:00:00Z`);
62+
return Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === date
63+
? timestamp : NaN;
64+
})
6065
.filter(Number.isFinite)
6166
.sort((a, b) => a - b);
6267

‎src/app.js‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { inject } from "@vercel/analytics";
2+
import { ANALYTICS_EVENTS, hasConsecutiveCheckinDays, recordAnalyticsEvent } from "./analytics.js";
23
import { getActiveHanziWritingPack } from "./content/hanzi-writing/manifest.js";
34
import {
45
renderWritingPrintSheetHtml,
@@ -171,6 +172,7 @@ function isProfileScopeBlocked() {
171172
}
172173

173174
const profileScopeBlockedAtStartup = isProfileScopeBlocked();
175+
if (!profileScopeBlockedAtStartup) recordAnalyticsEvent(ANALYTICS_EVENTS.activation, { once: true });
174176
const growthLoopDb = createIndexedDbLearningDb({ deferOpen: profileScopeBlockedAtStartup });
175177
const growthLoopController = createGrowthLoopController({
176178
db: growthLoopDb,
@@ -424,7 +426,13 @@ function toggleCheckin(mod){
424426
key: mod,
425427
});
426428
if (shouldRecordWorksheet) recordActiveWorksheetCompletion();
427-
save();
429+
const saved = save();
430+
if (saved && !wasChecked && hasCheckin(store.checkins[checkinDate], mod)) {
431+
recordAnalyticsEvent(ANALYTICS_EVENTS.firstCheckin, { once: true });
432+
if (hasConsecutiveCheckinDays(store.checkins, 3)) {
433+
recordAnalyticsEvent(ANALYTICS_EVENTS.threeDayStreak, { once: true });
434+
}
435+
}
428436
void queueGrowthActivity(
429437
ACTIVITY_EVENT_TYPES.GROWTH_ACTIVITY_RECORDED,
430438
{ source: "checkin", entry_type: "manual" },
@@ -720,6 +728,7 @@ async function speak(t, button, locale = "en-US", contentId = ""){
720728
button.setAttribute("aria-label", message);
721729
button.title = message;
722730
button.dataset.speechFailure = "true";
731+
if (button.isConnected) recordAnalyticsEvent(ANALYTICS_EVENTS.ttsFailed);
723732
const errorCode = message.includes("超时") ? "timeout" : message.includes("下载") ? "download_failed" : "synthesis_failed";
724733
void queueGrowthActivity(ACTIVITY_EVENT_TYPES.TTS_FAILED, {
725734
source: "published_tts",

‎src/cloud.js‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createClient } from "@supabase/supabase-js";
22
import { CLOUD_CONFIG } from "./config.js";
3+
import { ANALYTICS_EVENTS, recordAnalyticsEvent } from "./analytics.js";
34
import { escapeHtml, formatAuthError, formatCloudError, passwordStrength, stateHasData, mergeObjects, mergeState, latestUpdatedAt, GRADE_OPTIONS, gradeLabel, gradeOptionsSelected } from "./lib.js";
45
import { runLockedAction } from "./action-lock.js";
56
import { icon } from "./icons.js";
@@ -1710,6 +1711,7 @@ async function hydrateProfileRemote(profile, { migrateLocal = false, generation
17101711
if (error) {
17111712
markPerformance("shadow-mate:profile:remote-hydrate-failed");
17121713
showToast(formatCloudError(error, "读取云端记录失败,本机学习记录可继续使用。"), 5000);
1714+
recordAnalyticsEvent(ANALYTICS_EVENTS.syncFailed);
17131715
return;
17141716
}
17151717
const normalizedRemote = data ? normalizeCloudLearningState(data, scope) : null;
@@ -1840,10 +1842,12 @@ async function saveCloudState(manual = false, {
18401842
if (error) {
18411843
if (error.message.includes("learning_rate_limited")) {
18421844
showToast("操作过于频繁,请稍后再试。", 5000);
1845+
recordAnalyticsEvent(ANALYTICS_EVENTS.syncFailed);
18431846
break;
18441847
}
18451848
if (!error.message.includes("learning_state_conflict")) {
18461849
showToast(formatCloudError(error, "云端同步失败,请稍后再试。"), 5000);
1850+
recordAnalyticsEvent(ANALYTICS_EVENTS.syncFailed);
18471851
break;
18481852
}
18491853
// 旧服务端兼容:learning_state_conflict 错误 → 走下方统一冲突处理。
@@ -1864,6 +1868,7 @@ async function saveCloudState(manual = false, {
18641868
if (conflictRetries >= MAX_CONFLICT_RETRIES) {
18651869
if (canApplyActiveState()) cloudSyncBlocked = true;
18661870
showToast("云端记录冲突次数过多,自动同步已暂停,点击同步按钮重试。", 6000);
1871+
recordAnalyticsEvent(ANALYTICS_EVENTS.syncFailed);
18671872
break;
18681873
}
18691874

@@ -1883,8 +1888,10 @@ async function saveCloudState(manual = false, {
18831888
localStorage.removeItem(ACTIVE_PROFILE_KEY);
18841889
}
18851890
showToast("云端记录已不存在,已停止自动同步。", 6000);
1891+
recordAnalyticsEvent(ANALYTICS_EVENTS.syncFailed);
18861892
} else {
18871893
showToast(formatCloudError(remoteError, "读取最新云端记录失败,请稍后再试。"), 5000);
1894+
recordAnalyticsEvent(ANALYTICS_EVENTS.syncFailed);
18881895
}
18891896
break;
18901897
}

0 commit comments

Comments
 (0)