diff --git a/plugins/liuchen19960902/timezone-guard/.minimax-plugin/plugin.json b/plugins/liuchen19960902/timezone-guard/.minimax-plugin/plugin.json index e87d85d..130b944 100644 --- a/plugins/liuchen19960902/timezone-guard/.minimax-plugin/plugin.json +++ b/plugins/liuchen19960902/timezone-guard/.minimax-plugin/plugin.json @@ -2,8 +2,8 @@ "schemaVersion": 1, "name": "timezone-guard", "displayName": "A/时差保安", - "version": "1.0.1", - "description": "别在对方睡着的时区跟 Claude 聊天。A/时差保安实时对照对方当地时间:绿了正常聊,黄了短聊,红了先睡。", + "version": "1.0.2", + "description": "别在对方睡着的时区跟对方聊天。A/时差保安实时对照对方当地时间:绿了正常聊,黄了短聊,红了先睡。", "author": "FineCat", "icon": "icon.png", "category": "Productivity", diff --git a/plugins/liuchen19960902/timezone-guard/README.md b/plugins/liuchen19960902/timezone-guard/README.md index 66879f0..27bda3e 100644 --- a/plugins/liuchen19960902/timezone-guard/README.md +++ b/plugins/liuchen19960902/timezone-guard/README.md @@ -5,7 +5,11 @@ One clock, one verdict: is it a reasonable time to message someone in another time zone? The verdict follows the **other** city's local time, because what matters is whether *they* are awake. -Author: [FineCat](https://github.com/LiuChen19960902) · Version: `1.0.1` +> **The interface is Chinese only.** The plugin name, the verdict, the city names, the time periods +> and the date format are all in Chinese. This README and `README.zh-CN.md` are the two languages it +> ships in; the page itself has no language switch. + +Author: [FineCat](https://github.com/LiuChen19960902) · Version: `1.0.2` ![A/时差保安 showing the verdict, with the target city clock above the local one](docs/preview.png) @@ -97,9 +101,9 @@ never exercised — the page is a single responsive column either way. ## Data & access -- **Files read** — only the four static files inside its own package directory: - `miniapp/client/index.html`, `styles.css`, `cities.js`, `app.js`. Nothing outside the package is - read, and the disk is never scanned. +- **Files read** — only the five static files inside its own package directory: + `miniapp/client/index.html`, `styles.css`, `cities.js`, `time.js`, `app.js`. Nothing outside the + package is read, and the disk is never scanned. - **Files written** — none. `context.dataDir` is not used and nothing is written anywhere. - **Browser storage** — interface preferences only, under the key `timezone-guard.prefs.v1`: the selected pair of cities, the theme, and the 12/24-hour format. No session and no content data. @@ -117,20 +121,36 @@ package.json Mini App declaration miniapp/miniapp.json Payload roots, Node entry, page route miniapp/client/index.html The page served at /dashboard, including the SVG sprite miniapp/client/styles.css Visual layer -miniapp/client/app.js Time zone logic, verdict, city picker, 24-hour strip +miniapp/client/time.js Pure time calculations — shared by the browser and the tests +miniapp/client/app.js Verdict, city picker, 24-hour strip miniapp/client/cities.js City list and Chinese names for every time zone miniapp/node/server.mjs Node entry: start(context) → { dispose } miniapp/node/miniapp-api.ts Type declarations for the runtime context +test/time.test.mjs Unit tests for the time calculations docs/preview.png Screenshot of the page, light theme docs/preview-dark.png Screenshot of the page, dark theme icon.png Plugin icon ``` The page is plain HTML, CSS, and JavaScript — no framework, no CDN, no build step, fully offline. -The Node entry only serves `/dashboard` and those four files on the port the Host assigns. There +The Node entry only serves `/dashboard` and those five files on the port the Host assigns. There are **no third-party runtime dependencies**: the Node side uses built-in modules only, and the browser side uses native APIs only. +## Running the tests + +`miniapp/client/time.js` holds the time calculations, and the browser and Node run **the same +file** — there is no second copy to keep in sync. The unit tests ship with the package: + +```sh +npm test +``` + +Run it from the package directory. The suite covers the band thresholds landing exactly on the +hour (06:00 / 09:00 / 18:00 / 22:00), offset parsing including non-integral zones such as +`GMT+05:45`, the time-difference wording, and the calendar-day difference used for "still +yesterday". + ## License [MIT](./LICENSE). diff --git a/plugins/liuchen19960902/timezone-guard/README.zh-CN.md b/plugins/liuchen19960902/timezone-guard/README.zh-CN.md index cba106e..faca230 100644 --- a/plugins/liuchen19960902/timezone-guard/README.zh-CN.md +++ b/plugins/liuchen19960902/timezone-guard/README.zh-CN.md @@ -4,7 +4,9 @@ 发消息之前先看一眼对方那边几点。一个只有结论、没有废话的时差仪表:**该正常聊、该短聊、还是该先睡**,一律按对方城市的当地时间判定。 -作者:[FineCat](https://github.com/LiuChen19960902) · 版本:`1.0.1` +> **界面只有中文。** 插件名、判定文案、城市名、时段、日期格式都是中文,没有语言切换;本仓库提供中英两份 README,页面本身只有一种语言。 + +作者:[FineCat](https://github.com/LiuChen19960902) · 版本:`1.0.2` ![A/时差保安:判定区给出「短聊就好」,下方是对方城市与自己的两个时钟](docs/preview.png) @@ -67,7 +69,7 @@ MiniMax Code 桌面版 **3.1.4**,Windows 11 家庭版(10.0.26300,x64)。 ## 数据与访问范围 -- **读取的文件** —— 只有它自己包目录下的四个静态文件:`miniapp/client/index.html`、`styles.css`、`cities.js`、`app.js`。不读包目录以外的任何文件,也不扫描磁盘。 +- **读取的文件** —— 只有它自己包目录下的五个静态文件:`miniapp/client/index.html`、`styles.css`、`cities.js`、`time.js`、`app.js`。不读包目录以外的任何文件,也不扫描磁盘。 - **写入的文件** —— 无。不写 `context.dataDir`,不写任何地方。 - **浏览器存储** —— 只存界面偏好,键名 `timezone-guard.prefs.v1`:选中的两个城市、主题、12H/24H 制式。不存会话、不存任何内容数据。 - **网络** —— 无。页面只从插件自己的回环服务加载静态资源,没有遥测,没有远程字体、图标或脚本。 @@ -77,7 +79,17 @@ MiniMax Code 桌面版 **3.1.4**,Windows 11 家庭版(10.0.26300,x64)。 ## 源码与验证情况 -页面是 `miniapp/client/index.html`(无框架、无 CDN、无构建步骤,离线可用),视觉层在 `styles.css`,逻辑在 `app.js`,城市库与全时区中文名在 `cities.js`;Node 入口是 `miniapp/node/server.mjs`,它只负责把 `/dashboard` 和这四个静态文件挂到宿主分配的端口上;供编辑器类型检查用的 Host API 类型定义在 `miniapp/node/miniapp-api.ts`。**没有任何第三方运行时依赖** —— Node 侧只用内置模块,浏览器侧只用原生 API。 +页面是 `miniapp/client/index.html`(无框架、无 CDN、无构建步骤,离线可用),视觉层在 `styles.css`,判定与交互在 `app.js`,城市库与全时区中文名在 `cities.js`,纯时间计算在 `time.js`;Node 入口是 `miniapp/node/server.mjs`,它只负责把 `/dashboard` 和这五个静态文件挂到宿主分配的端口上;供编辑器类型检查用的 Host API 类型定义在 `miniapp/node/miniapp-api.ts`。**没有任何第三方运行时依赖** —— Node 侧只用内置模块,浏览器侧只用原生 API。 + +### 跑测试 + +`miniapp/client/time.js` 装着全部时间计算,浏览器和 Node 跑的是**同一个文件**,不存在第二份要同步的副本。单测随包分发: + +```sh +npm test +``` + +在包目录下执行。覆盖四个阈值正好落在整点(06:00 / 09:00 / 18:00 / 22:00)、偏移解析(含 `GMT+05:45` 这类非整小时时区)、时差文案,以及「对方还在昨天」用的日历日差。 ## 许可 diff --git a/plugins/liuchen19960902/timezone-guard/miniapp/client/app.js b/plugins/liuchen19960902/timezone-guard/miniapp/client/app.js index a7e36c7..8554285 100644 --- a/plugins/liuchen19960902/timezone-guard/miniapp/client/app.js +++ b/plugins/liuchen19960902/timezone-guard/miniapp/client/app.js @@ -6,6 +6,15 @@ * 时间与偏移全部通过 IANA 时区 + Intl 计算,夏令时由运行时 tzdata 自动处理。 */ +/* ------------------------------------------------------------------ * + * 纯时间计算 + * ------------------------------------------------------------------ */ + +// 时区规范化、档位判定、偏移解析、时差文案都放在 time.js 里: +// 那份文件浏览器与 Node 共用同一份源码,可以直接跑 node --test, +// 也不用在这里和别处各留一份、改一处漏一处。 +const { resolveZone, periodOf, parseOffset, formatDiff, dayDelta } = window.TZ_TIME; + /* ------------------------------------------------------------------ * * 城市库 * ------------------------------------------------------------------ */ @@ -76,6 +85,12 @@ const BAND_COPY = { }, }; +/** + * 档位判定在 time.js 里,返回的是档位 key;这里补上文案, + * 调用点拿到的仍然是原来那个对象,用法一个字都不用改。 + */ +const bandOf = (hour, minute) => BAND_COPY[window.TZ_TIME.bandOf(hour, minute)]; + /** 城市旁的昼夜图标:太阳 / 半日 / 月亮。 */ const SKY_ICONS = { sun: '#i-sun', dusk: '#i-dusk', moon: '#i-moon' }; @@ -88,49 +103,6 @@ const SKY_ICONS = { sun: '#i-sun', dusk: '#i-dusk', moon: '#i-moon' }; /** @type {Entry[]} */ let INDEX = []; -/** - * 部分 IANA 名称在不同 ICU / tzdata 版本下存在与否不同 - * (Europe/Kyiv vs Europe/Kiev、Asia/Kolkata vs Asia/Calcutta …), - * 这里为它们准备退路,保证任何时候都拿得到一个可用时区。 - */ -const ZONE_FALLBACKS = { - 'Europe/Kyiv': ['Europe/Kiev'], - 'Asia/Ho_Chi_Minh': ['Asia/Saigon'], - 'Asia/Kolkata': ['Asia/Calcutta'], - 'America/Montreal': ['America/Toronto'], - 'America/Argentina/Buenos_Aires': ['America/Rosario'], -}; - -const resolvedZoneCache = new Map(); - -/** - * 返回一个在当前运行时确实可用的时区 ID;都不行时返回 null。 - * - * 注意:城市名对应的时区名不一定存在。旧金山、西雅图没有独立的 - * America/* 时区,慕尼黑没有 Europe/Munich——这类城市必须归到 - * 本国主时区,否则 new Intl.DateTimeFormat() 会抛 RangeError。 - */ -function resolveZone(zone) { - if (typeof zone !== 'string' || !zone) return null; - const trimmed = zone.trim(); - if (!trimmed) return null; - if (resolvedZoneCache.has(trimmed)) return resolvedZoneCache.get(trimmed); - let resolved = null; - for (const candidate of [trimmed, ...(ZONE_FALLBACKS[trimmed] || [])]) { - try { - // 取规范化后的 ID,避免同一个时区出现大小写 / 别名两种写法, - // 让「是否同一时区」的比较始终可靠。 - const canonical = new Intl.DateTimeFormat('en-US', { timeZone: candidate }).resolvedOptions().timeZone; - resolved = canonical || candidate; - break; - } catch { - /* 试下一个候选 */ - } - } - resolvedZoneCache.set(trimmed, resolved); - return resolved; -} - function buildIndex() { const zones = typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : []; /** @type {Entry[]} */ @@ -344,15 +316,6 @@ function getFormatters(timeZone) { return f; } -/** 把 "GMT-07:00" / "GMT+05:30" / "GMT" 解析成分钟偏移。 */ -function parseOffset(text) { - if (!text || text === 'GMT' || text === 'UTC') return 0; - const m = /GMT([+-])(\d{1,2})(?::(\d{2}))?/.exec(text); - if (!m) return 0; - const sign = m[1] === '-' ? -1 : 1; - return sign * (Number(m[2]) * 60 + Number(m[3] || 0)); -} - /** * 取某个时区在给定时刻的完整信息。 * 时区 ID 先做一次解析兜底,绝不因为一个非法时区让整页渲染抛错。 @@ -386,45 +349,14 @@ function zonedInfo(timeZone, date) { }; } -/** 中文时段,替代 AM/PM:凌晨 / 上午 / 下午 / 晚上。 */ -function periodOf(hour) { - if (hour < 6) return '凌晨'; - if (hour < 12) return '上午'; - if (hour < 18) return '下午'; - return '晚上'; -} - -/** 固定阈值判定:绿 09–18、黄 18–22、红 22–06,清晨 06–09 归黄。 */ -function bandOf(hour, minute) { - const t = hour * 60 + minute; - if (t >= 22 * 60 || t < 6 * 60) return BAND_COPY.night; - if (t < 9 * 60) return BAND_COPY.dawn; - if (t < 18 * 60) return BAND_COPY.day; - return BAND_COPY.dusk; -} - /** 绿 < 黄 < 红,用于比较两边的紧迫程度。 */ const SEVERITY = { green: 0, yellow: 1, red: 2 }; -/** - * 时差文案。 - * 整小时沿用「约 N 小时」;存在 45 分钟这类非整小时偏移时给出精确的 - * 小时 + 分钟,避免读者拿 5.8 小时去对旁边并排的两个时钟。 - */ -function formatDiff(diffMinutes) { - const abs = Math.abs(diffMinutes); - const hours = Math.floor(abs / 60); - const minutes = abs % 60; - if (minutes === 0) return `约 ${hours} 小时`; - // 非整小时时带一个前导空格,拼到「快/慢」后面才不会连成一团。 - return hours > 0 ? ` ${hours} 小时 ${minutes} 分钟` : ` ${minutes} 分钟`; -} - /* ------------------------------------------------------------------ * * 状态 * ------------------------------------------------------------------ */ -const STORAGE_KEY = 'timezone-guard.prefs.v1'; +const STORAGE_KEY = window.TZ_TIME.STORAGE_KEY; const DEFAULT_TARGET = { zone: 'America/Los_Angeles', @@ -760,7 +692,10 @@ function render() { const homeAhead = diffMinutes > 0; const ahead = homeAhead ? state.home.title : state.target.title; const behind = homeAhead ? state.target.title : state.home.title; - const parts = [`${ahead}比${behind}快${formatDiff(diffMinutes)}`]; + // formatDiff 只回纯文本:整小时是「约 15 小时」,那个「约」自带分词; + // 非整小时是「5 小时 45 分钟」,直接接在「快」后面会连成一团,这里补一个空格。 + const gap = diffMinutes % 60 === 0 ? '' : ' '; + const parts = [`${ahead}比${behind}快${gap}${formatDiff(diffMinutes)}`]; const dayDiff = dayDelta(target, home); if (dayDiff < 0) parts.push('对方还在昨天'); else if (dayDiff > 0) parts.push('对方那边已是明天'); @@ -768,13 +703,6 @@ function render() { } } -/** 目标日期相对对照日期的日历天数差。 */ -function dayDelta(target, home) { - const t = Date.UTC(target.year, target.month - 1, target.day); - const h = Date.UTC(home.year, home.month - 1, home.day); - return Math.round((t - h) / 86400000); -} - /** 当我方比对方更晚 / 都处于深夜时,补一条给用户自己的提醒。 */ function extraNote(band, homeBand) { if (SEVERITY[homeBand.color] > SEVERITY[band.color]) { diff --git a/plugins/liuchen19960902/timezone-guard/miniapp/client/index.html b/plugins/liuchen19960902/timezone-guard/miniapp/client/index.html index a548777..cdad958 100644 --- a/plugins/liuchen19960902/timezone-guard/miniapp/client/index.html +++ b/plugins/liuchen19960902/timezone-guard/miniapp/client/index.html @@ -5,6 +5,7 @@ A/时差保安 +