Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 26 additions & 6 deletions plugins/liuchen19960902/timezone-guard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand All @@ -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).
18 changes: 15 additions & 3 deletions plugins/liuchen19960902/timezone-guard/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 制式。不存会话、不存任何内容数据。
- **网络** —— 无。页面只从插件自己的回环服务加载静态资源,没有遥测,没有远程字体、图标或脚本。
Expand All @@ -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` 这类非整小时时区)、时差文案,以及「对方还在昨天」用的日历日差。

## 许可

Expand Down
112 changes: 20 additions & 92 deletions plugins/liuchen19960902/timezone-guard/miniapp/client/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@
* 时间与偏移全部通过 IANA 时区 + Intl 计算,夏令时由运行时 tzdata 自动处理。
*/

/* ------------------------------------------------------------------ *
* 纯时间计算
* ------------------------------------------------------------------ */

// 时区规范化、档位判定、偏移解析、时差文案都放在 time.js 里:
// 那份文件浏览器与 Node 共用同一份源码,可以直接跑 node --test,
// 也不用在这里和别处各留一份、改一处漏一处。
const { resolveZone, periodOf, parseOffset, formatDiff, dayDelta } = window.TZ_TIME;

/* ------------------------------------------------------------------ *
* 城市库
* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -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' };

Expand All @@ -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[]} */
Expand Down Expand Up @@ -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 先做一次解析兜底,绝不因为一个非法时区让整页渲染抛错。
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -760,21 +692,17 @@ 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('对方那边已是明天');
setText(el.factDiff, parts.join(' · '));
}
}

/** 目标日期相对对照日期的日历天数差。 */
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]) {
Expand Down
41 changes: 19 additions & 22 deletions plugins/liuchen19960902/timezone-guard/miniapp/client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,29 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>A/时差保安</title>
<link rel="icon" href="data:," />
<script src="./time.js"></script>
<script>
// 在首帧之前定好主题、时间制式与状态色,
// 避免深色闪一帧浅色、先按 12H 画一帧再翻转,
// 或者出现「黄色图标配红色文案」这种自相矛盾的第一帧。
(function () {
var theme = 'light';
var fmt = '12h';
var saved = {};
try {
var saved = JSON.parse(localStorage.getItem('timezone-guard.prefs.v1') || '{}');
var t = saved && saved.theme;
if (t !== 'light' && t !== 'dark') {
t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
theme = t;
if (saved && saved.clockFormat === '24h') fmt = '24h';
saved = JSON.parse(localStorage.getItem(window.TZ_TIME.STORAGE_KEY) || '{}') || {};
} catch (error) {
/* 存档不可用时用默认值 */
}
// 存档只读一次:body 里那段脚本直接复用这里解析好的结果,
// 两处各读一次、各解析一次,早晚会读出不一样的结果。
window.TZ_PREFS = saved;
var t = saved.theme;
if (t !== 'light' && t !== 'dark') {
t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
theme = t;
if (saved.clockFormat === '24h') fmt = '24h';
document.documentElement.dataset.theme = theme;
document.documentElement.dataset.fmt = fmt;
document.documentElement.dataset.band = 'neutral';
Expand Down Expand Up @@ -198,18 +203,9 @@ <h1 class="page-title" id="page-title">
// 城市名在 body 里,等到这里元素已经存在,才能在第一次绘制前写好,
// 免得先画一遍默认城市再翻转成用户上次选的城市。
(function () {
// 与 app.js 的 resolveZone 同义:把时区 ID 规范化后再比较,
// 否则 'asia/shanghai' 与 'Asia/Shanghai' 会被判成两个城市。
function canonZone(z) {
if (typeof z !== 'string' || !z) return z;
try {
return new Intl.DateTimeFormat('en-US', { timeZone: z }).resolvedOptions().timeZone || z;
} catch (error) {
return z;
}
}
try {
var saved = JSON.parse(localStorage.getItem('timezone-guard.prefs.v1') || '{}');
// head 里已经解析过一遍存档,这里直接复用,不再读第二次。
var saved = window.TZ_PREFS || {};
if (!saved) return;
var t = saved.target && saved.target.title;
var h = saved.home && saved.home.title;
Expand All @@ -221,10 +217,11 @@ <h1 class="page-title" id="page-title">
document.getElementById('page-title-home').textContent = h;
document.getElementById('home-heading').textContent = h;
}
if (
t && h && t === h &&
canonZone(saved.target && saved.target.zone) === canonZone(saved.home && saved.home.zone)
) {
// 是否同一个城市:和 app.js 用同一套判定(规范化后的时区 ID),
// 两处口径一致,才不会出现「先隐藏、下一帧又被画回来」。
var targetZone = window.TZ_TIME.resolveZone(saved.target && saved.target.zone);
var homeZone = window.TZ_TIME.resolveZone(saved.home && saved.home.zone);
if (t && h && t === h && targetZone && homeZone && targetZone === homeZone) {
document.getElementById('page-title-home').style.display = 'none';
document.getElementById('page-title-swap').style.display = 'none';
}
Expand Down
Loading
Loading