Compare commits
2 Commits
0448ec8b72
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| cf941c50bc | |||
|
|
1b67e15071 |
@@ -22,6 +22,7 @@ timeTableFix/
|
|||||||
│ ├── lib/
|
│ ├── lib/
|
||||||
│ │ ├── weekday.js 星期常量与工具
|
│ │ ├── weekday.js 星期常量与工具
|
||||||
│ │ ├── date.js 日期工具
|
│ │ ├── date.js 日期工具
|
||||||
|
│ │ ├── timezone.js 时区检测 / 选择(回退墨尔本)
|
||||||
│ │ ├── colors.js 事件配色
|
│ │ ├── colors.js 事件配色
|
||||||
│ │ ├── uuid.js UUID 生成(含非安全上下文回退)
|
│ │ ├── uuid.js UUID 生成(含非安全上下文回退)
|
||||||
│ │ ├── ical-io.js ICS 文本 <-> plain model
|
│ │ ├── ical-io.js ICS 文本 <-> plain model
|
||||||
@@ -60,9 +61,11 @@ npm install # 已配 npmmirror 镜像加速
|
|||||||
|
|
||||||
npm run dev # 开发服务器 http://localhost:5173
|
npm run dev # 开发服务器 http://localhost:5173
|
||||||
npm run build # 构建到 dist/
|
npm run build # 构建到 dist/
|
||||||
npm test # 单元测试(42 项)
|
npm test # 单元测试(57 项)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
顶栏时区选择器在加载时自动检测用户所在时区,检测不到则默认澳大利亚墨尔本;仅作为新建事件与下载 ICS 的默认时区,不做时间换算。详见 [`docs/timezone.md`](./docs/timezone.md)。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
| 项 | 选择 |
|
| 项 | 选择 |
|
||||||
@@ -78,6 +81,7 @@ npm test # 单元测试(42 项)
|
|||||||
## 了解更多
|
## 了解更多
|
||||||
|
|
||||||
- [嵌入 mainPage(i18n prop 桥接 / 样式隔离)](./docs/integration.md)
|
- [嵌入 mainPage(i18n prop 桥接 / 样式隔离)](./docs/integration.md)
|
||||||
|
- [时区选择(自动检测 / 回退墨尔本)](./docs/timezone.md)
|
||||||
- [间隔合并与链拆分算法](./docs/organize-algorithm.md)
|
- [间隔合并与链拆分算法](./docs/organize-algorithm.md)
|
||||||
- [自动保存设计](./docs/auto-save-design.md)
|
- [自动保存设计](./docs/auto-save-design.md)
|
||||||
- [初始设计文档](./docs/superpowers/specs/2026-07-13-timetable2-design.md)
|
- [初始设计文档](./docs/superpowers/specs/2026-07-13-timetable2-design.md)
|
||||||
|
|||||||
49
docs/timezone.md
Normal file
49
docs/timezone.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# 时区选择
|
||||||
|
|
||||||
|
timeTableFix 顶栏提供时区选择器。加载时自动检测用户所在时区,检测不到则默认 **澳大利亚墨尔本(`Australia/Melbourne`)**。
|
||||||
|
|
||||||
|
## 检测逻辑
|
||||||
|
|
||||||
|
`src/lib/timezone.js` 的 `detectTimezone()`:
|
||||||
|
|
||||||
|
1. 读取 `new Intl.DateTimeFormat().resolvedOptions().timeZone`(浏览器/Node 标准 API)。
|
||||||
|
2. 若抛错、返回空串或格式不合法,回退 `DEFAULT_TIMEZONE`(`Australia/Melbourne`)。
|
||||||
|
|
||||||
|
非安全上下文(无 `https`)或极旧环境可能拿不到时区,此时一律回退墨尔本。
|
||||||
|
|
||||||
|
## 下拉列表来源
|
||||||
|
|
||||||
|
`listTimezones()` = 精选常用 IANA 时区(`COMMON_TIMEZONES`,含墨尔本/上海/UTC 等)∪ 运行时 `Intl.supportedValuesOf('timeZone')`(若可用),去重后排序,`UTC` 置顶。旧浏览器不支持 `supportedValuesOf` 时静默降级为仅精选列表,保证下拉始终可用、无外部依赖。
|
||||||
|
|
||||||
|
## 作用范围(设计要点)
|
||||||
|
|
||||||
|
应用内部所有日期/时间均为 **wall-clock 字符串**(`YYYY-MM-DD` + `HH:MM`,见 `date.js`),**不做任何时区换算**。时区仅是一个挂在事件上的 IANA 标识(`tzid`),在 ICS 序列化/反序列化时作为 `DTSTART`/`DTEND` 的 `TZID` 参数读写。
|
||||||
|
|
||||||
|
因此选定时区的作用是**日历级默认值/标签**,而非时间换算层:
|
||||||
|
|
||||||
|
| 行为 | 是否受选定时区影响 |
|
||||||
|
|---|---|
|
||||||
|
| 新建事件(「新增日程」)的 `tzid` | ✅ 使用选定时区 |
|
||||||
|
| 下载 ICS 的 `X-WR-TIMEZONE` | ✅ 写入选定时区 |
|
||||||
|
| 已导入事件的 `tzid` | ❌ 保留原始 TZID,不覆盖、不换算 |
|
||||||
|
| 日期/时间数值(编辑、预览、整理) | ❌ 始终 wall-clock,不换算 |
|
||||||
|
| 间隔去重(`organize.js`) | ❌ 不读 `tzid`,不受影响 |
|
||||||
|
|
||||||
|
### 为何不换算
|
||||||
|
|
||||||
|
- 应用面向「整理/编辑日程文本」而非「跨时区会议调度」,wall-clock 直观且无歧义。
|
||||||
|
- `organize.js` 按 `[summary, location, 开始时间, 时长]` 合并重复,引入换算会改变合并键、破坏既有 round-trip 测试。
|
||||||
|
- 已导入事件保留原 TZID 保证 `parse(serialize(parse(x)))` 等价(见 `ical-io.test.js` round-trip 用例)。
|
||||||
|
|
||||||
|
## 实现位置
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `src/lib/timezone.js` | 新增:检测/列表/校验/默认值 |
|
||||||
|
| `src/composables/useCalendar.js` | `state.timezone`(初始 `detectTimezone()`)、`setTimezone()`、`addEvent` 传入 tzid、`serialize` 注入 `X-WR-TIMEZONE` |
|
||||||
|
| `src/components/HeaderBar.vue` | 顶栏 `<select>` 选择器 |
|
||||||
|
| `src/i18n/locales/{zh-CN,en}.js` | `header.timezone` 文案 |
|
||||||
|
|
||||||
|
## 嵌入 mainPage
|
||||||
|
|
||||||
|
时区状态属于 timeTableFix 自有单例 store,嵌入 mainPage 时随子模块独立运作,不与父项目共享。语言切换仍经 `:locale` prop 桥接(与时区无关)。
|
||||||
@@ -2,10 +2,12 @@
|
|||||||
import { useCalendar } from '../composables/useCalendar.js'
|
import { useCalendar } from '../composables/useCalendar.js'
|
||||||
import { useLocale } from '../composables/useLocale.js'
|
import { useLocale } from '../composables/useLocale.js'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { listTimezones } from '../lib/timezone.js'
|
||||||
|
|
||||||
const { state, organize, serialize, loadFile } = useCalendar()
|
const { state, organize, serialize, loadFile, setTimezone } = useCalendar()
|
||||||
const { t } = useLocale()
|
const { t } = useLocale()
|
||||||
const fileInput = ref(null)
|
const fileInput = ref(null)
|
||||||
|
const timezones = listTimezones()
|
||||||
|
|
||||||
function onOrganize() {
|
function onOrganize() {
|
||||||
const stats = organize()
|
const stats = organize()
|
||||||
@@ -44,10 +46,16 @@ function onDownload() {
|
|||||||
<button class="btn btn-primary" @click="onOrganize" :disabled="state.events.length === 0">
|
<button class="btn btn-primary" @click="onOrganize" :disabled="state.events.length === 0">
|
||||||
{{ t('header.organize') }}
|
{{ t('header.organize') }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary" @click="onImport">{{ t('header.import') }}</button>
|
<button class="btn btn-primary" @click="onImport">{{ t('header.import') }}</button>
|
||||||
<button class="btn btn-success" @click="onDownload" :disabled="state.events.length === 0">
|
<button class="btn btn-success" @click="onDownload" :disabled="state.events.length === 0">
|
||||||
{{ t('header.download') }}
|
{{ t('header.download') }}
|
||||||
</button>
|
</button>
|
||||||
|
<label class="tz-picker">
|
||||||
|
<span class="tz-label">{{ t('header.timezone') }}</span>
|
||||||
|
<select :value="state.timezone" @change="setTimezone($event.target.value)" :title="t('header.timezone')">
|
||||||
|
<option v-for="tz in timezones" :key="tz" :value="tz">{{ tz }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<input ref="fileInput" type="file" accept=".ics" @change="onFilePicked" hidden />
|
<input ref="fileInput" type="file" accept=".ics" @change="onFilePicked" hidden />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -74,8 +82,27 @@ function onDownload() {
|
|||||||
.btn-secondary { background: var(--bg); color: var(--text-secondary); }
|
.btn-secondary { background: var(--bg); color: var(--text-secondary); }
|
||||||
.btn-secondary:hover:not(:disabled) { background: var(--surface); color: var(--text-primary); }
|
.btn-secondary:hover:not(:disabled) { background: var(--surface); color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* 时区选择器:与按钮同行,紧凑 */
|
||||||
|
.tz-picker { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.tz-label { font-size: 12px; color: var(--text-secondary); white-space: nowrap; }
|
||||||
|
.tz-picker select {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 170px;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
}
|
||||||
|
.tz-picker select:focus { outline: none; border-color: var(--accent); }
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.header-actions { gap: 6px; }
|
.header-actions { gap: 6px; }
|
||||||
.btn { padding: 5px 10px; font-size: 12px; }
|
.btn { padding: 5px 10px; font-size: 12px; }
|
||||||
|
.tz-label { display: none; }
|
||||||
|
.tz-picker select { padding: 5px 8px; font-size: 12px; max-width: 130px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { organize as organizeEvents } from '../lib/organize.js'
|
|||||||
import { createEvent } from '../lib/event-factory.js'
|
import { createEvent } from '../lib/event-factory.js'
|
||||||
import { splitRecurringEvent } from '../lib/split.js'
|
import { splitRecurringEvent } from '../lib/split.js'
|
||||||
import { todayISO, addDays, startOfMonth, parseISODate, pad2 } from '../lib/date.js'
|
import { todayISO, addDays, startOfMonth, parseISODate, pad2 } from '../lib/date.js'
|
||||||
|
import { detectTimezone, isValidTimezone, DEFAULT_TIMEZONE } from '../lib/timezone.js'
|
||||||
|
|
||||||
// 单例响应式状态
|
// 单例响应式状态
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
@@ -13,6 +14,9 @@ const state = reactive({
|
|||||||
fileName: null,
|
fileName: null,
|
||||||
viewMode: 'edit', // 'edit' | 'month' | 'week'
|
viewMode: 'edit', // 'edit' | 'month' | 'week'
|
||||||
previewDate: todayISO(), // 预览锚定日期 (月预览取当月, 周预览取当周)
|
previewDate: todayISO(), // 预览锚定日期 (月预览取当月, 周预览取当周)
|
||||||
|
// 日历级默认时区: 加载时自动检测用户所在时区, 取不到回退墨尔本。
|
||||||
|
// 仅作新建事件的默认 tzid 与下载 ICS 的 X-WR-TIMEZONE, 不做时间换算。
|
||||||
|
timezone: detectTimezone(),
|
||||||
})
|
})
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
@@ -59,7 +63,7 @@ function deleteEvent(uid) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addEvent() {
|
function addEvent() {
|
||||||
const ev = createEvent()
|
const ev = createEvent({ tzid: state.timezone })
|
||||||
state.events.push(ev)
|
state.events.push(ev)
|
||||||
state.selectedUid = ev.uid
|
state.selectedUid = ev.uid
|
||||||
}
|
}
|
||||||
@@ -93,7 +97,12 @@ function toggleOccurrence(uid, date) {
|
|||||||
// 序列化 (下载时兜底: 始终序列化当前最新状态)
|
// 序列化 (下载时兜底: 始终序列化当前最新状态)
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
function serialize() {
|
function serialize() {
|
||||||
return serializeICS({ meta: state.meta, events: state.events })
|
// 把当前选定默认时区写入 X-WR-TIMEZONE, 使下载的 ICS 携带该标识。
|
||||||
|
// 保留 meta 原值用于回显, 仅在输出时覆盖 (不污染内存状态)。
|
||||||
|
const meta = state.meta
|
||||||
|
? { ...state.meta, xWrTimezone: state.timezone }
|
||||||
|
: state.meta
|
||||||
|
return serializeICS({ meta, events: state.events })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
@@ -103,6 +112,15 @@ function setViewMode(mode) {
|
|||||||
state.viewMode = mode
|
state.viewMode = mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置日历级默认时区(新建事件与 X-WR-TIMEZONE 用)。
|
||||||
|
* 校验失败时回退到默认时区(墨尔本),保证 state 永远持有合法 IANA 标识。
|
||||||
|
* @param {string} tz
|
||||||
|
*/
|
||||||
|
function setTimezone(tz) {
|
||||||
|
state.timezone = isValidTimezone(tz) ? tz : DEFAULT_TIMEZONE
|
||||||
|
}
|
||||||
|
|
||||||
function shiftMonth(delta) {
|
function shiftMonth(delta) {
|
||||||
const { year, month } = parseISODate(startOfMonth(state.previewDate))
|
const { year, month } = parseISODate(startOfMonth(state.previewDate))
|
||||||
const d = new Date(year, month - 1 + delta, 1)
|
const d = new Date(year, month - 1 + delta, 1)
|
||||||
@@ -158,6 +176,7 @@ export function useCalendar() {
|
|||||||
toggleOccurrence,
|
toggleOccurrence,
|
||||||
serialize,
|
serialize,
|
||||||
setViewMode,
|
setViewMode,
|
||||||
|
setTimezone,
|
||||||
prevMonth,
|
prevMonth,
|
||||||
nextMonth,
|
nextMonth,
|
||||||
prevWeek,
|
prevWeek,
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export default {
|
|||||||
import: 'Import File',
|
import: 'Import File',
|
||||||
download: 'Download ICS',
|
download: 'Download ICS',
|
||||||
noPattern: 'No recurring patterns found to merge',
|
noPattern: 'No recurring patterns found to merge',
|
||||||
parseError: 'Parse failed: '
|
parseError: 'Parse failed: ',
|
||||||
|
timezone: 'Time Zone'
|
||||||
},
|
},
|
||||||
dropzone: {
|
dropzone: {
|
||||||
hint: 'Drop an .ics file, or',
|
hint: 'Drop an .ics file, or',
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export default {
|
|||||||
import: '导入文件',
|
import: '导入文件',
|
||||||
download: '下载 ICS',
|
download: '下载 ICS',
|
||||||
noPattern: '未发现可合并的重复模式',
|
noPattern: '未发现可合并的重复模式',
|
||||||
parseError: '解析失败:'
|
parseError: '解析失败:',
|
||||||
|
timezone: '时区'
|
||||||
},
|
},
|
||||||
dropzone: {
|
dropzone: {
|
||||||
hint: '拖入 .ics 文件,或',
|
hint: '拖入 .ics 文件,或',
|
||||||
|
|||||||
102
src/lib/timezone.js
Normal file
102
src/lib/timezone.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* 时区选择辅助函数。
|
||||||
|
*
|
||||||
|
* 应用内部所有日期/时间均为 wall-clock 字符串(见 date.js),不做时区换算。
|
||||||
|
* 本模块仅负责「选择一个 IANA 时区标识」作为日历级默认值:检测浏览器所在
|
||||||
|
* 时区,取不到则回退澳大利亚墨尔本(Australia/Melbourne);并提供下拉列表。
|
||||||
|
*
|
||||||
|
* 选定时区用于:新建事件的默认 tzid、下载 ICS 的 X-WR-TIMEZONE。
|
||||||
|
* 已导入事件保留其原始 TZID 不变(不做换算、不覆盖)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 检测失败时的回退时区(澳大利亚墨尔本)。 */
|
||||||
|
export const DEFAULT_TIMEZONE = 'Australia/Melbourne'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 精选常用 IANA 时区,保证下拉框在任意环境(含不支持
|
||||||
|
* Intl.supportedValuesOf 的旧浏览器)下都有合理可选项。
|
||||||
|
* 列表覆盖主要大洲 + UTC,墨尔本位列其中。
|
||||||
|
*/
|
||||||
|
export const COMMON_TIMEZONES = [
|
||||||
|
'UTC',
|
||||||
|
'Australia/Melbourne',
|
||||||
|
'Australia/Sydney',
|
||||||
|
'Australia/Perth',
|
||||||
|
'Asia/Shanghai',
|
||||||
|
'Asia/Hong_Kong',
|
||||||
|
'Asia/Tokyo',
|
||||||
|
'Asia/Singapore',
|
||||||
|
'Asia/Seoul',
|
||||||
|
'Asia/Kolkata',
|
||||||
|
'Asia/Dubai',
|
||||||
|
'Europe/London',
|
||||||
|
'Europe/Paris',
|
||||||
|
'Europe/Berlin',
|
||||||
|
'Europe/Moscow',
|
||||||
|
'America/New_York',
|
||||||
|
'America/Chicago',
|
||||||
|
'America/Denver',
|
||||||
|
'America/Los_Angeles',
|
||||||
|
'America/Toronto',
|
||||||
|
'Pacific/Auckland',
|
||||||
|
]
|
||||||
|
|
||||||
|
/** IANA 时区标识粗校验:至少含一个 '/',且仅含可打印 ASCII。 */
|
||||||
|
const IANA_RE = /^[A-Za-z0-9_+\-]+(\/[A-Za-z0-9_+\-]+)+$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验字符串是否为合法的 IANA 时区标识。
|
||||||
|
* UTC 与 Etc/GMT 等单段形式不被本应用使用,故要求至少一段 '/'。
|
||||||
|
* @param {string} tz
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isValidTimezone(tz) {
|
||||||
|
if (typeof tz !== 'string' || tz.length === 0) return false
|
||||||
|
if (!IANA_RE.test(tz)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收集下拉框可选时区:精选列表 ∪ 浏览器支持的时区,去重后排序。
|
||||||
|
* 运行时若 Intl.supportedValuesOf 不可用则仅返回精选列表。
|
||||||
|
* UTC 始终置顶(用户可见的全局协调时区)。
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function listTimezones() {
|
||||||
|
const set = new Set(COMMON_TIMEZONES)
|
||||||
|
try {
|
||||||
|
if (typeof Intl !== 'undefined' && typeof Intl.supportedValuesOf === 'function') {
|
||||||
|
for (const tz of Intl.supportedValuesOf('timeZone')) {
|
||||||
|
if (isValidTimezone(tz)) set.add(tz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 旧环境或不支持时静默降级到精选列表
|
||||||
|
}
|
||||||
|
const list = [...set].sort((a, b) => {
|
||||||
|
if (a === 'UTC') return -1
|
||||||
|
if (b === 'UTC') return 1
|
||||||
|
return a.localeCompare(b)
|
||||||
|
})
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测用户当前所在时区。
|
||||||
|
* 优先用 Intl.DateTimeFormat().resolvedOptions().timeZone;取不到或抛错
|
||||||
|
* 时回退 DEFAULT_TIMEZONE(澳大利亚墨尔本)。
|
||||||
|
* @returns {string} IANA 时区标识
|
||||||
|
*/
|
||||||
|
export function detectTimezone() {
|
||||||
|
try {
|
||||||
|
const tz =
|
||||||
|
typeof Intl !== 'undefined' &&
|
||||||
|
typeof Intl.DateTimeFormat === 'function'
|
||||||
|
? new Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
: null
|
||||||
|
if (tz && isValidTimezone(tz)) return tz
|
||||||
|
} catch {
|
||||||
|
// 非安全上下文或 Intl 缺失:回退
|
||||||
|
}
|
||||||
|
return DEFAULT_TIMEZONE
|
||||||
|
}
|
||||||
127
test/timezone.test.js
Normal file
127
test/timezone.test.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import {
|
||||||
|
DEFAULT_TIMEZONE,
|
||||||
|
COMMON_TIMEZONES,
|
||||||
|
detectTimezone,
|
||||||
|
listTimezones,
|
||||||
|
isValidTimezone,
|
||||||
|
} from '../src/lib/timezone.js'
|
||||||
|
import { createEvent } from '../src/lib/event-factory.js'
|
||||||
|
|
||||||
|
describe('DEFAULT_TIMEZONE', () => {
|
||||||
|
it('is Australia/Melbourne per requirement', () => {
|
||||||
|
expect(DEFAULT_TIMEZONE).toBe('Australia/Melbourne')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isValidTimezone', () => {
|
||||||
|
it('accepts valid IANA zone ids', () => {
|
||||||
|
expect(isValidTimezone('Australia/Melbourne')).toBe(true)
|
||||||
|
expect(isValidTimezone('Asia/Shanghai')).toBe(true)
|
||||||
|
expect(isValidTimezone('America/New_York')).toBe(true)
|
||||||
|
expect(isValidTimezone('Europe/London')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty / non-string / malformed', () => {
|
||||||
|
expect(isValidTimezone('')).toBe(false)
|
||||||
|
expect(isValidTimezone(null)).toBe(false)
|
||||||
|
expect(isValidTimezone(undefined)).toBe(false)
|
||||||
|
expect(isValidTimezone(123)).toBe(false)
|
||||||
|
expect(isValidTimezone('UTC')).toBe(false) // 单段, 本应用要求至少一段 '/'
|
||||||
|
expect(isValidTimezone('Not A Zone')).toBe(false)
|
||||||
|
expect(isValidTimezone('Bogus/Zone/With Space')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('detectTimezone', () => {
|
||||||
|
// 保存原始 Intl 引用以便恢复
|
||||||
|
const origDateTimeFormat = Intl.DateTimeFormat
|
||||||
|
afterEach(() => {
|
||||||
|
Intl.DateTimeFormat = origDateTimeFormat
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a valid IANA zone in a normal environment', () => {
|
||||||
|
// Node 环境总能解析出系统时区
|
||||||
|
const tz = detectTimezone()
|
||||||
|
expect(isValidTimezone(tz)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to Melbourne when Intl.DateTimeFormat throws', () => {
|
||||||
|
Intl.DateTimeFormat = function () {
|
||||||
|
throw new Error('unsupported')
|
||||||
|
}
|
||||||
|
expect(detectTimezone()).toBe(DEFAULT_TIMEZONE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to Melbourne when resolved timeZone is empty', () => {
|
||||||
|
Intl.DateTimeFormat = function () {
|
||||||
|
return { resolvedOptions: () => ({ timeZone: '' }) }
|
||||||
|
}
|
||||||
|
expect(detectTimezone()).toBe(DEFAULT_TIMEZONE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to Melbourne when resolved timeZone is malformed', () => {
|
||||||
|
Intl.DateTimeFormat = function () {
|
||||||
|
return { resolvedOptions: () => ({ timeZone: 'Not A Zone' }) }
|
||||||
|
}
|
||||||
|
expect(detectTimezone()).toBe(DEFAULT_TIMEZONE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the detected zone when it is valid', () => {
|
||||||
|
Intl.DateTimeFormat = function () {
|
||||||
|
return { resolvedOptions: () => ({ timeZone: 'Asia/Shanghai' }) }
|
||||||
|
}
|
||||||
|
expect(detectTimezone()).toBe('Asia/Shanghai')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('listTimezones', () => {
|
||||||
|
it('always includes Melbourne, Shanghai, UTC', () => {
|
||||||
|
const list = listTimezones()
|
||||||
|
expect(list).toContain('Australia/Melbourne')
|
||||||
|
expect(list).toContain('Asia/Shanghai')
|
||||||
|
expect(list).toContain('UTC')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has no duplicates', () => {
|
||||||
|
const list = listTimezones()
|
||||||
|
expect(new Set(list).size).toBe(list.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('places UTC first, rest sorted', () => {
|
||||||
|
const list = listTimezones()
|
||||||
|
expect(list[0]).toBe('UTC')
|
||||||
|
const rest = list.slice(1)
|
||||||
|
const sorted = [...rest].sort((a, b) => a.localeCompare(b))
|
||||||
|
expect(rest).toEqual(sorted)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every entry is a valid IANA zone (UTC excepted)', () => {
|
||||||
|
const list = listTimezones()
|
||||||
|
for (const tz of list) {
|
||||||
|
if (tz === 'UTC') continue
|
||||||
|
expect(isValidTimezone(tz)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes all COMMON_TIMEZONES entries', () => {
|
||||||
|
const list = listTimezones()
|
||||||
|
for (const tz of COMMON_TIMEZONES) {
|
||||||
|
expect(list).toContain(tz)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createEvent timezone override', () => {
|
||||||
|
// 确认 useCalendar.addEvent 依赖的覆盖路径:传入 { tzid } 后新建事件的
|
||||||
|
// tzid 即为该值(而非 event-factory 的独立默认 'UTC')。
|
||||||
|
it('honors the tzid passed via overrides', () => {
|
||||||
|
const ev = createEvent({ tzid: 'Asia/Shanghai' })
|
||||||
|
expect(ev.tzid).toBe('Asia/Shanghai')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to UTC when no tzid override is given (standalone default)', () => {
|
||||||
|
const ev = createEvent()
|
||||||
|
expect(ev.tzid).toBe('UTC')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user