Repository navigation
Expand file tree
/
Copy pathapp.js
More file actions
498 lines (448 loc) · 17.8 KB
/
Copy pathapp.js
File metadata and controls
498 lines (448 loc) · 17.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
// MeetSync — 跨时区会议时间协调器
// 时区计算全部基于浏览器 Intl API,自动处理夏令时
// v2:任意日期规划 + 会议时长 + 15 分钟精度拖动选取
const $ = (sel) => document.querySelector(sel);
const SLOTS = 96; // 24h × 4,每槽 15 分钟
const SLOT_MIN = 15;
// ---------- 主题切换:暗色 / 亮色 / 跟随系统 ----------
function setupTheme(storageKey) {
const MODES = ["auto", "light", "dark"];
const LABELS = { auto: "🖥 跟随系统", light: "☀️ 亮色", dark: "🌙 暗色" };
const btn = $("#themeBtn");
let mode = localStorage.getItem(storageKey);
if (!MODES.includes(mode)) mode = "auto";
function apply() {
if (mode === "auto") document.documentElement.removeAttribute("data-theme");
else document.documentElement.dataset.theme = mode;
btn.textContent = LABELS[mode];
localStorage.setItem(storageKey, mode);
}
btn.onclick = () => {
mode = MODES[(MODES.indexOf(mode) + 1) % MODES.length];
apply();
};
apply();
}
const state = {
cities: [], // [{label, tz}]
workStart: 9,
workEnd: 18,
date: todayStr(), // 会议日期 YYYY-MM-DD(本地)
startMin: null, // 会议开始(本地当日分钟数,15 的倍数)
durMin: 60, // 会议时长(分钟)
};
// ---------- 时区工具 ----------
function todayStr() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
// 本地 state.date 当天第 min 分钟对应的 Date
function slotDate(min) {
const [y, m, d] = state.date.split("-").map(Number);
return new Date(y, m - 1, d, 0, min);
}
// Intl formatter 很贵,按时区缓存(96 槽 × 多城市渲染时性能关键)
const fmtCache = {};
function getFmts(tz) {
if (!fmtCache[tz]) {
fmtCache[tz] = {
time: new Intl.DateTimeFormat("zh-CN", {
timeZone: tz, hour: "2-digit", minute: "2-digit",
weekday: "short", hourCycle: "h23",
}),
day: new Intl.DateTimeFormat("en-CA", { timeZone: tz }), // YYYY-MM-DD
};
}
return fmtCache[tz];
}
const localDayFmt = new Intl.DateTimeFormat("en-CA");
// 某个 Date 在指定时区的 { h, m, weekday, dayDiff }(dayDiff 相对该 Date 的本地日期)
function zonedTime(date, tz) {
const f = getFmts(tz);
const parts = f.time.formatToParts(date);
const get = (t) => parts.find((p) => p.type === t)?.value;
const isoLocal = localDayFmt.format(date);
const isoZoned = f.day.format(date);
return {
h: parseInt(get("hour"), 10),
m: parseInt(get("minute"), 10),
weekday: get("weekday"),
iso: isoZoned,
dayDiff: isoZoned === isoLocal ? 0 : (isoZoned > isoLocal ? 1 : -1),
};
}
function statusOf(h, m, workStart, workEnd) {
const t = h + m / 60;
if (t >= workStart && t < workEnd) return "work";
if (t >= 7 && t < 23) return "awake";
return "sleep";
}
function cityStatusAt(city, min) {
const z = zonedTime(slotDate(min), city.tz);
return statusOf(z.h, z.m, state.workStart, state.workEnd);
}
// 每城市 96 槽状态表(渲染前统一计算)
function buildGrid() {
return state.cities.map((c) =>
Array.from({ length: SLOTS }, (_, s) => cityStatusAt(c, s * SLOT_MIN))
);
}
const pad2 = (n) => String(n).padStart(2, "0");
const minToHM = (min) => `${pad2(Math.floor((min % 1440) / 60))}:${pad2(min % 60)}`;
function durText(min) {
const h = Math.floor(min / 60), m = min % 60;
return h && m ? `${h} 小时 ${m} 分` : h ? `${h} 小时` : `${m} 分钟`;
}
// 定长版:分钟固定两位,文本宽度不随数值变化(避免页面布局跳动)
function durTextFixed(min) {
return `${Math.floor(min / 60)} 小时 ${pad2(min % 60)} 分`;
}
// ---------- 状态持久化 ----------
function saveState() {
localStorage.setItem("meetsync", JSON.stringify({
tzs: state.cities.map((c) => c.tz),
workStart: state.workStart,
workEnd: state.workEnd,
durMin: state.durMin,
}));
}
function loadState() {
// URL hash 优先: #tz=Asia/Shanghai,America/New_York&d=2026-07-10&s=540&len=60
const hash = new URLSearchParams(location.hash.slice(1));
let tzs = null;
if (hash.get("tz")) {
tzs = hash.get("tz").split(",").filter(Boolean);
if (hash.get("d")) state.date = hash.get("d");
if (hash.get("s")) state.startMin = +hash.get("s");
if (hash.get("len")) state.durMin = +hash.get("len");
} else {
try {
const saved = JSON.parse(localStorage.getItem("meetsync"));
if (saved) {
tzs = saved.tzs;
state.workStart = saved.workStart ?? 9;
state.workEnd = saved.workEnd ?? 18;
state.durMin = saved.durMin ?? 60;
}
} catch (_) {}
}
if (!tzs || !tzs.length) {
const mine = Intl.DateTimeFormat().resolvedOptions().timeZone;
tzs = [mine, "America/New_York", "Europe/London"];
}
state.cities = tzs.map((tz) => {
const known = CITIES.find((c) => c.tz === tz);
return known ? { ...known } : { label: tz.split("/").pop().replace(/_/g, " "), tz };
});
const seen = new Set();
state.cities = state.cities.filter((c) => {
const key = c.label + c.tz;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
// 默认把会议放在第一个全员工作的槽位(放不下就 9:00)
function defaultStart(grid) {
for (let s = 0; s <= SLOTS - state.durMin / SLOT_MIN; s++) {
const span = state.durMin / SLOT_MIN;
let ok = true;
for (let k = 0; k < span && ok; k++) {
ok = grid.every((row) => row[s + k] === "work");
}
if (ok) return s * SLOT_MIN;
}
return 9 * 60;
}
// ---------- 渲染 ----------
function render() {
const grid = buildGrid();
if (state.startMin == null) state.startMin = defaultStart(grid);
clampMeeting();
renderTimeline(grid);
renderSummary(grid);
renderDetail();
saveState();
}
function clampMeeting() {
state.durMin = Math.max(SLOT_MIN, Math.min(480, state.durMin));
state.startMin = Math.max(0, Math.min(1440 - state.durMin, state.startMin ?? 0));
$("#durSlider").value = state.durMin;
$("#durLabel").textContent = durTextFixed(state.durMin);
}
function bandHtml() {
return `<div class="sel-band"><div class="h hl"></div><div class="h hr"></div></div>`;
}
function updateBandVars() {
$("#timeline").style.setProperty("--sel-l", `${(state.startMin / 1440) * 100}%`);
$("#timeline").style.setProperty("--sel-w", `${(state.durMin / 1440) * 100}%`);
}
function renderTimeline(grid) {
const wrap = $("#timeline");
wrap.innerHTML = "";
updateBandVars();
// 小时刻度
const scale = document.createElement("div");
scale.className = "tl-row";
scale.innerHTML = `<div class="tl-label">本地时间 →</div>
<div class="tl-scale">${Array.from({ length: 24 }, (_, h) => `<span>${h}</span>`).join("")}</div>`;
wrap.appendChild(scale);
// 当前时刻竖线(仅当选的日期是今天)
const isToday = state.date === todayStr();
const nowPct = ((new Date().getHours() * 60 + new Date().getMinutes()) / 1440) * 100;
const nowLine = isToday ? `<div class="now-line" style="left:${nowPct}%"></div>` : "";
// 全员重叠行
const header = document.createElement("div");
header.className = "tl-row header";
const headSlots = Array.from({ length: SLOTS }, (_, s) => {
const col = grid.map((row) => row[s]);
let cls = "none";
if (col.length && col.every((x) => x === "work")) cls = "all-work";
else if (col.length && col.every((x) => x !== "sleep")) cls = "all-awake";
return `<div class="slot ${cls}${s % 4 === 0 ? " hour-mark" : ""}" title="${minToHM(s * SLOT_MIN)}"></div>`;
}).join("");
header.innerHTML = `<div class="tl-label">☀️ 全员重叠</div>
<div class="tl-cells">${headSlots}${nowLine}${bandHtml()}</div>`;
wrap.appendChild(header);
// 城市行
state.cities.forEach((city, idx) => {
const row = document.createElement("div");
row.className = "tl-row";
const now = zonedTime(new Date(), city.tz);
// 槽位 + 跨天徽标:该城市日期相对所选日期发生变化处,标注 +1天 / -1天
let slots = "", badges = "", prevDiff = null;
for (let s = 0; s < SLOTS; s++) {
const st = grid[idx][s];
const z = zonedTime(slotDate(s * SLOT_MIN), city.tz);
if (z.dayDiff !== prevDiff) {
if (z.dayDiff !== 0) {
badges += `<span class="day-badge" style="left:${(s / SLOTS) * 100}%">${z.dayDiff > 0 ? "+1天" : "-1天"}</span>`;
}
prevDiff = z.dayDiff;
}
slots += `<div class="slot ${st}${s % 4 === 0 ? " hour-mark" : ""}"
title="本地 ${minToHM(s * SLOT_MIN)} = ${city.label} ${pad2(z.h)}:${pad2(z.m)}${z.dayDiff ? (z.dayDiff > 0 ? "(次日)" : "(前一日)") : ""}"></div>`;
}
slots += badges;
row.innerHTML = `
<div class="tl-label">
<div>
<span class="city-name">${city.label}</span>
<span class="city-now">现在 ${now.weekday} ${pad2(now.h)}:${pad2(now.m)}</span>
</div>
<button class="rm" title="移除">✕</button>
</div>
<div class="tl-cells">${slots}${nowLine}${bandHtml()}</div>`;
row.querySelector(".rm").onclick = () => {
state.cities.splice(idx, 1);
render();
};
wrap.appendChild(row);
});
}
function renderSummary(grid) {
const el = $("#overlapSummary");
if (state.cities.length < 2) {
el.innerHTML = `➕ 请添加至少两个城市来查看重叠的工作时间`;
return;
}
const good = [];
for (let s = 0; s < SLOTS; s++) {
if (grid.every((row) => row[s] === "work")) good.push(s);
}
if (!good.length) {
el.innerHTML = `<span class="bad">⚠️ ${state.date} 当天没有全员工作时间重叠(工作时间 ${state.workStart}:00–${state.workEnd}:00)。</span>
可以参考「全员重叠」行里的浅黄色段(全员清醒),或调整工作时间范围。`;
return;
}
const ranges = [];
let start = good[0], prev = good[0];
for (let i = 1; i <= good.length; i++) {
if (good[i] === prev + 1) { prev = good[i]; continue; }
ranges.push([start * SLOT_MIN, (prev + 1) * SLOT_MIN]);
start = good[i]; prev = good[i];
}
const chips = ranges.map(([a, b]) =>
`<span class="range-chip" data-min="${a}" title="点击把会议放到这里">${minToHM(a)} – ${minToHM(b)}</span>`
).join("");
el.innerHTML = `<span class="good">✅ ${state.date} 全员工作时间重叠(你的本地时间,点击可放置会议):</span><br>${chips}`;
el.querySelectorAll(".range-chip").forEach((chip) => {
chip.onclick = () => { state.startMin = +chip.dataset.min; render(); };
});
}
// 会议时段内某城市的整体状态:
// 全部工作 → work;涉及休息 → sleep;工作+清醒混合 → 部分在外;全程清醒 → 在外但清醒
function meetingStatus(cityIdx, grid) {
const s0 = state.startMin / SLOT_MIN;
const s1 = (state.startMin + state.durMin) / SLOT_MIN;
let anySleep = false, anyWork = false, anyAwake = false;
for (let s = s0; s < s1; s++) {
const st = grid[cityIdx][s];
if (st === "sleep") anySleep = true;
else if (st === "work") anyWork = true;
else anyAwake = true;
}
if (anySleep) return { cls: "sleep", text: anyWork || anyAwake ? "跨到休息时间!" : "在休息时间!" };
if (!anyAwake) return { cls: "work", text: "全程工作时间" };
if (anyWork) return { cls: "awake", text: "部分在工作时间外" };
return { cls: "awake", text: "工作时间外 · 清醒" };
}
function renderDetail() {
const el = $("#meetingDetail");
if (state.startMin == null || !state.cities.length) { el.classList.add("hidden"); return; }
el.classList.remove("hidden");
const grid = buildGrid();
const endMin = state.startMin + state.durMin;
const rows = state.cities.map((c, i) => {
const zs = zonedTime(slotDate(state.startMin), c.tz);
const ze = zonedTime(slotDate(endMin), c.tz);
const st = meetingStatus(i, grid);
const dayNote = zs.dayDiff ? (zs.dayDiff > 0 ? "(次日)" : "(前一日)") : "";
return `<tr>
<td>${c.label}</td>
<td><b>${zs.iso}(${zs.weekday})${pad2(zs.h)}:${pad2(zs.m)} – ${pad2(ze.h)}:${pad2(ze.m)}</b>${dayNote}
<span class="tag ${st.cls}">${st.text}</span></td>
<td>${c.tz}</td>
</tr>`;
}).join("");
el.innerHTML = `<h3>📌 会议:${state.date} ${minToHM(state.startMin)} – ${minToHM(endMin)}(你的本地时间,${durText(state.durMin)})</h3>
<table>${rows}</table>
<div class="actions"><button id="copyInvite">📋 复制会议邀请文本</button></div>`;
$("#copyInvite").onclick = async () => {
const lines = state.cities.map((c) => {
const zs = zonedTime(slotDate(state.startMin), c.tz);
const ze = zonedTime(slotDate(endMin), c.tz);
return `- ${c.label}: ${zs.iso}(${zs.weekday})${pad2(zs.h)}:${pad2(zs.m)} – ${pad2(ze.h)}:${pad2(ze.m)}`;
});
const text = `会议时间提议(时长 ${durText(state.durMin)}):\n${lines.join("\n")}`;
try { await navigator.clipboard.writeText(text); } catch (_) { prompt("复制以下内容:", text); }
$("#copyInvite").textContent = "✅ 已复制";
setTimeout(() => { $("#copyInvite").textContent = "📋 复制会议邀请文本"; }, 1500);
};
}
// ---------- 拖动交互(移动 / 两端把手缩放,15 分钟吸附) ----------
function setupDrag() {
const timeline = $("#timeline");
let drag = null; // { mode: 'move'|'l'|'r', grabOffset }
const snap = (min) => Math.round(min / SLOT_MIN) * SLOT_MIN;
function minAt(e, cells) {
const rect = cells.getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
return snap(Math.max(0, Math.min(1440, ratio * 1440)));
}
timeline.addEventListener("pointerdown", (e) => {
const cells = e.target.closest(".tl-cells");
if (!cells) return;
e.preventDefault();
const min = minAt(e, cells);
const endMin = state.startMin + state.durMin;
if (e.target.classList.contains("hl")) {
drag = { mode: "l", cells };
} else if (e.target.classList.contains("hr")) {
drag = { mode: "r", cells };
} else if (e.target.closest(".sel-band")) {
drag = { mode: "move", cells, grabOffset: min - state.startMin };
} else {
// 空白处点击:把会议开始放到这里,随后可继续拖
state.startMin = Math.min(min, 1440 - state.durMin);
drag = { mode: "move", cells, grabOffset: 0 };
updateBandVars();
}
timeline.setPointerCapture?.(e.pointerId);
});
timeline.addEventListener("pointermove", (e) => {
if (!drag) return;
const min = minAt(e, drag.cells);
const endMin = state.startMin + state.durMin;
if (drag.mode === "move") {
state.startMin = Math.max(0, Math.min(1440 - state.durMin, min - drag.grabOffset));
} else if (drag.mode === "l") {
const newStart = Math.max(0, Math.min(endMin - SLOT_MIN, min));
state.durMin = endMin - newStart;
state.startMin = newStart;
} else if (drag.mode === "r") {
state.durMin = Math.max(SLOT_MIN, Math.min(480, Math.min(1440, min) - state.startMin));
}
clampMeeting();
updateBandVars(); // 拖动中只挪色带,不整页重绘
});
const finish = () => {
if (!drag) return;
drag = null;
render(); // 松手后刷新摘要和详情
};
timeline.addEventListener("pointerup", finish);
timeline.addEventListener("pointercancel", finish);
}
// ---------- 搜索 ----------
function setupSearch() {
const input = $("#citySearch");
const list = $("#cityResults");
input.addEventListener("input", () => {
const q = input.value.trim().toLowerCase();
if (!q) { list.classList.add("hidden"); return; }
const matches = CITIES.filter((c) =>
c.label.toLowerCase().includes(q) || c.tz.toLowerCase().includes(q)
).slice(0, 10);
list.innerHTML = matches.length
? matches.map((c, i) => `<li data-i="${i}">${c.label}<span class="tz">${c.tz}</span></li>`).join("")
: `<li style="cursor:default;color:var(--muted)">没有匹配的城市</li>`;
list.classList.remove("hidden");
list.querySelectorAll("li[data-i]").forEach((li) => {
li.onclick = () => {
const c = matches[+li.dataset.i];
if (!state.cities.some((x) => x.label === c.label)) state.cities.push({ ...c });
input.value = "";
list.classList.add("hidden");
render();
};
});
});
document.addEventListener("click", (e) => {
if (!e.target.closest(".add-city")) list.classList.add("hidden");
});
}
// ---------- 控件 ----------
function setupControls() {
const s = $("#workStart"), e = $("#workEnd");
for (let h = 0; h < 24; h++) {
s.add(new Option(`${h}:00`, h));
e.add(new Option(`${h}:00`, h));
}
s.value = state.workStart;
e.value = state.workEnd;
s.onchange = () => { state.workStart = +s.value; render(); };
e.onchange = () => { state.workEnd = +e.value; render(); };
const dateInput = $("#meetDate");
dateInput.value = state.date;
dateInput.min = todayStr();
dateInput.onchange = () => {
if (!dateInput.value) return;
state.date = dateInput.value;
state.startMin = null; // 换日期后重新推荐时段
render();
};
const slider = $("#durSlider");
slider.value = state.durMin;
slider.oninput = () => { state.durMin = +slider.value; render(); };
$("#shareBtn").onclick = async () => {
const url = `${location.origin}${location.pathname}#tz=${state.cities.map((c) => c.tz).join(",")}` +
`&d=${state.date}&s=${state.startMin}&len=${state.durMin}`;
try {
await navigator.clipboard.writeText(url);
$("#shareBtn").textContent = "✅ 已复制";
} catch (_) {
prompt("复制此链接分享:", url);
}
setTimeout(() => { $("#shareBtn").textContent = "🔗 分享链接"; }, 1500);
};
}
// ---------- 启动 ----------
setupTheme("meetsync-theme");
loadState();
setupControls();
setupSearch();
setupDrag();
render();
// 每分钟刷新「现在」时间与当前时刻竖线
setInterval(render, 60 * 1000);