From 402e2e950b7c873d9565969d6aa130b82c3eadd6 Mon Sep 17 00:00:00 2001 From: eric Date: Sat, 29 Aug 2026 01:26:07 -0500 Subject: [PATCH] Add daylight work window and bag weight estimator with polished UI. Co-authored-by: Cursor --- README.md | 2 + frontend/src/app/changelog/page.tsx | 4 +- frontend/src/app/globals.css | 240 ++++++++++++++++++ frontend/src/app/tools/page.tsx | 4 +- .../src/components/BagWeightEstimator.tsx | 136 ++++++++++ frontend/src/components/DaylightWorkHours.tsx | 151 +++++++++++ frontend/src/components/HomeClient.tsx | 4 + frontend/src/components/ToolsStrip.tsx | 4 +- frontend/src/lib/tools.ts | 2 + 9 files changed, 542 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/BagWeightEstimator.tsx create mode 100644 frontend/src/components/DaylightWorkHours.tsx diff --git a/README.md b/README.md index abe1d81..9255ef1 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,8 @@ docker compose up -d | 🎯 启动金目标 | 存款进度追踪,预估出发时间 | | 🚀 旅居跑道 | 存款 ÷ 月开销,算出还能飞多久 | | 🤝 会议黄金时段 | 双时区工作重叠可视化 | +| ☀️ 日照办公窗 | 日出日落与深度工作建议 | +| ⚖️ 行李重量 | 对照航司限额估算装备重量 | | ✨ 今日金句 | 每日游民灵感语录 | | 😴 时差估算 | 时差跨度与恢复期建议 | | 🏡 住宿指南 | Villa / Coliving / 公寓方案对比 | diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx index 137a479..6a6ebf7 100644 --- a/frontend/src/app/changelog/page.tsx +++ b/frontend/src/app/changelog/page.tsx @@ -12,9 +12,11 @@ const LOGS = [ date: "2026-08-29", tag: "新工具", items: [ + "日照办公窗:按城市月份估算日出日落与深度工作时段", + "行李重量估算:勾选装备对照航司限额", "旅居跑道计算器:存款 + 月开销一眼算出还能飞多久", "会议黄金时段:双时区工作重叠可视化时间轴", - "快捷工具条更新,直达跑道 / 会议 / 汇率", + "快捷工具条更新,直达日照 / 行李 / 跑道 / 会议", ], }, { diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 371c9a7..1356810 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -7872,3 +7872,243 @@ img { max-width: 100%; display: block; } .meeting-bars { gap: 2px; } .meeting-cell { min-height: 36px; } } + + +/* ===== Daylight Work Hours ===== */ +.daylight-card { + padding: 24px; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-xl); + backdrop-filter: blur(16px); + display: flex; + flex-direction: column; + gap: 20px; +} +.daylight-controls { + display: flex; + flex-direction: column; + gap: 12px; +} +.daylight-controls label { + display: flex; + flex-direction: column; + gap: 6px; + max-width: 320px; +} +.daylight-controls label > span, +.bag-extra > span { + font-size: 0.85rem; + color: var(--text-secondary); +} +.daylight-controls select { + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.1); + border-radius: var(--radius-sm); + color: var(--text-primary); + padding: 10px 12px; +} +.daylight-months { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.daylight-month { + padding: 6px 10px; + border-radius: 999px; + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.03); + color: var(--text-secondary); + cursor: pointer; + font-size: 0.8rem; + transition: all 0.2s; +} +.daylight-month:hover, +.daylight-month.active { + color: var(--text-primary); + border-color: rgba(255, 230, 109, 0.45); + background: rgba(255, 230, 109, 0.12); +} +.daylight-sky { + position: relative; + height: 72px; + border-radius: var(--radius-md); + background: linear-gradient(90deg, #0b1224, #1a2744 40%, #2a1f3d 70%, #0b1224); + overflow: hidden; + border: 1px solid rgba(255,255,255,0.06); +} +.daylight-band { + position: absolute; + top: 0; + bottom: 0; + background: linear-gradient(180deg, rgba(255,230,109,0.45), rgba(255,107,107,0.15)); +} +.daylight-deep { + position: absolute; + top: 18px; + bottom: 18px; + background: rgba(78, 205, 196, 0.55); + border-radius: 6px; + box-shadow: 0 0 16px rgba(78, 205, 196, 0.35); +} +.daylight-markers { + position: absolute; + inset: 0; + pointer-events: none; +} +.daylight-markers span { + position: absolute; + bottom: 6px; + transform: translateX(-50%); + font-size: 0.65rem; + color: rgba(241,245,249,0.7); +} +.daylight-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + margin-top: 12px; +} +.daylight-stats small { + display: block; + color: var(--text-muted); + font-size: 0.75rem; +} +.daylight-stats strong { + font-family: var(--font-display); + font-size: 1.25rem; +} +.daylight-tips { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +.daylight-tip { + display: flex; + gap: 12px; + padding: 14px; + border-radius: var(--radius-md); + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.06); +} +.daylight-tip > span { font-size: 1.4rem; } +.daylight-tip strong { display: block; margin-bottom: 4px; font-size: 0.95rem; } +.daylight-tip p { margin: 0; color: var(--text-secondary); font-size: 0.85rem; line-height: 1.45; } + +/* ===== Bag Weight ===== */ +.bag-card { + padding: 24px; + background: var(--bg-card); + border: var(--border-glass); + border-radius: var(--radius-xl); + backdrop-filter: blur(16px); + display: flex; + flex-direction: column; + gap: 18px; +} +.bag-gauge { + height: 10px; + border-radius: 999px; + background: rgba(255,255,255,0.08); + overflow: hidden; +} +.bag-gauge-fill { + height: 100%; + border-radius: 999px; + transition: width 0.35s ease, background 0.25s; +} +.bag-meter-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + margin-top: 10px; +} +.bag-meter-row strong { + font-family: var(--font-display); + font-size: 1.8rem; +} +.bag-meter-row small { color: var(--text-muted); } +.bag-status { font-weight: 700; } +.bag-tip { color: var(--text-secondary); font-size: 0.9rem; margin: 6px 0 0; } +.bag-limits { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} +.bag-limit { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 10px 6px; + border-radius: var(--radius-md); + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.03); + cursor: pointer; + color: inherit; + transition: all 0.2s; +} +.bag-limit:hover, +.bag-limit.active { + border-color: rgba(78, 205, 196, 0.4); + background: rgba(78, 205, 196, 0.1); +} +.bag-limit em { font-style: normal; font-size: 0.8rem; font-weight: 600; } +.bag-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} +.bag-item { + display: flex; + align-items: center; + gap: 10px; + padding: 12px; + border-radius: var(--radius-md); + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.03); + cursor: pointer; + color: inherit; + text-align: left; + transition: all 0.2s; +} +.bag-item:hover { transform: translateY(-1px); } +.bag-item.on { + border-color: rgba(78, 205, 196, 0.4); + background: rgba(78, 205, 196, 0.1); +} +.bag-item > span { font-size: 1.25rem; } +.bag-item strong { display: block; font-size: 0.88rem; } +.bag-item small { color: var(--text-muted); } +.bag-item i { + margin-left: auto; + font-style: normal; + width: 22px; + height: 22px; + border-radius: 50%; + display: grid; + place-items: center; + background: rgba(255,255,255,0.08); + font-size: 0.75rem; +} +.bag-item.on i { background: var(--accent-3); color: #0a0e17; font-weight: 700; } +.bag-extra { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 12px; + align-items: center; +} +.bag-extra input[type=range] { width: 100%; accent-color: var(--accent-3); } +.bag-extra em { font-style: normal; font-weight: 600; color: var(--accent-3); min-width: 3.5rem; text-align: right; } + +@media (max-width: 900px) { + .daylight-tips { grid-template-columns: 1fr; } + .daylight-stats { grid-template-columns: repeat(2, 1fr); } + .bag-grid { grid-template-columns: repeat(2, 1fr); } + .bag-limits { grid-template-columns: repeat(2, 1fr); } +} +@media (max-width: 560px) { + .bag-grid { grid-template-columns: 1fr; } + .bag-extra { grid-template-columns: 1fr; } +} diff --git a/frontend/src/app/tools/page.tsx b/frontend/src/app/tools/page.tsx index 174a3b4..a3bff3c 100644 --- a/frontend/src/app/tools/page.tsx +++ b/frontend/src/app/tools/page.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import { TOOL_CATEGORIES, TOOL_LINKS } from "@/lib/tools"; import SiteShell from "@/components/SiteShell"; -const FEATURED = ["runway", "meetings", "trip", "calculator", "visa", "focus"]; +const FEATURED = ["daylight", "bag-weight", "runway", "meetings", "trip", "focus"]; export default function ToolsHubPage() { const [filter, setFilter] = useState("all"); @@ -41,7 +41,7 @@ export default function ToolsHubPage() {
✨ 本周推荐 - 新上线 · 跑道 & 会议窗口 + 新上线 · 日照窗 & 行李重量
{featured.map((t) => ( diff --git a/frontend/src/components/BagWeightEstimator.tsx b/frontend/src/components/BagWeightEstimator.tsx new file mode 100644 index 0000000..11a2632 --- /dev/null +++ b/frontend/src/components/BagWeightEstimator.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useMemo, useState } from "react"; + +interface Gear { + id: string; + emoji: string; + label: string; + kg: number; + group: "tech" | "wear" | "daily"; +} + +const GEAR: Gear[] = [ + { id: "laptop", emoji: "💻", label: "笔记本电脑", kg: 1.4, group: "tech" }, + { id: "charger", emoji: "🔌", label: "充电套装", kg: 0.4, group: "tech" }, + { id: "tablet", emoji: "📱", label: "平板 / 手机", kg: 0.5, group: "tech" }, + { id: "headset", emoji: "🎧", label: "耳机", kg: 0.3, group: "tech" }, + { id: "camera", emoji: "📷", label: "相机", kg: 0.8, group: "tech" }, + { id: "clothes", emoji: "👕", label: "衣物一周", kg: 2.5, group: "wear" }, + { id: "shoes", emoji: "👟", label: "鞋 x2", kg: 1.2, group: "wear" }, + { id: "jacket", emoji: "🧥", label: "外套", kg: 0.9, group: "wear" }, + { id: "toiletry", emoji: "🧴", label: "洗漱包", kg: 0.6, group: "daily" }, + { id: "meds", emoji: "💊", label: "药品杂物", kg: 0.4, group: "daily" }, + { id: "book", emoji: "📖", label: "书籍 / 周边", kg: 0.7, group: "daily" }, + { id: "bottle", emoji: "💧", label: "水瓶(空)", kg: 0.3, group: "daily" }, +]; + +const LIMITS = [ + { key: "cabin7", label: "随身 7kg", max: 7, emoji: "🎒" }, + { key: "cabin10", label: "随身 10kg", max: 10, emoji: "🧳" }, + { key: "check20", label: "托运 20kg", max: 20, emoji: "✈️" }, + { key: "check23", label: "托运 23kg", max: 23, emoji: "🛫" }, +]; + +export default function BagWeightEstimator() { + const [selected, setSelected] = useState(["laptop", "charger", "headset", "clothes", "shoes", "toiletry"]); + const [limit, setLimit] = useState("cabin10"); + const [extra, setExtra] = useState(0.5); + + const total = useMemo(() => { + const base = GEAR.filter((g) => selected.includes(g.id)).reduce((s, g) => s + g.kg, 0); + return Math.round((base + extra) * 10) / 10; + }, [selected, extra]); + + const lim = LIMITS.find((l) => l.key === limit)!; + const pct = Math.min((total / lim.max) * 100, 100); + const over = total > lim.max; + const room = Math.round((lim.max - total) * 10) / 10; + + const toggle = (id: string) => { + setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); + }; + + const status = over + ? { label: "超重预警", color: "#FF6B6B", tip: `超出约 ${Math.abs(room)} kg,建议精简或升舱额` } + : room <= 1 + ? { label: "接近上限", color: "#FFE66D", tip: "余量很少,登机前再称一次" } + : { label: "轻装通过", color: "#4ECDC4", tip: `还可再装约 ${room} kg` }; + + return ( +
+
+
+ ⚖️ BAG WEIGHT +

行李重量估算

+

勾选装备,对照航司限额,出发前心里有数

+
+ +
+
+
+
+
+
+
+ {total} kg + / {lim.max} kg · {lim.emoji} {lim.label} +
+ {status.label} +
+

{status.tip}

+
+ +
+ {LIMITS.map((l) => ( + + ))} +
+ +
+ {GEAR.map((g) => { + const on = selected.includes(g.id); + return ( + + ); + })} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/DaylightWorkHours.tsx b/frontend/src/components/DaylightWorkHours.tsx new file mode 100644 index 0000000..08d251e --- /dev/null +++ b/frontend/src/components/DaylightWorkHours.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { Destination } from "@/lib/types"; + +/** Approximate daylight by latitude + month (no API). */ +const CITY_META: Record = { + chiangmai: { lat: 18.8, tip: "热带全年日照长,午后雷阵雨多见" }, + bali: { lat: -8.4, tip: "赤道附近昼夜接近 12h,早晚温和好出门" }, + lisbon: { lat: 38.7, tip: "冬短夏长,夏季晚上 9 点仍明亮" }, + barcelona: { lat: 41.4, tip: "地中海日照充沛,冬日仍可户外办公" }, + mexico: { lat: 19.4, tip: "高原日照强,注意防晒与补水" }, + tokyo: { lat: 35.7, tip: "四季分明,冬季日落早适合室内专注" }, +}; + +function approxDayLength(lat: number, month: number): number { + const m = month - 1; + const decl = 23.44 * Math.sin(((284 + month * 30.4) * Math.PI) / 180); + const latRad = (lat * Math.PI) / 180; + const declRad = (decl * Math.PI) / 180; + const cosH = -Math.tan(latRad) * Math.tan(declRad); + const clamped = Math.max(-1, Math.min(1, cosH)); + const h = Math.acos(clamped); + void m; + return (2 * h * 12) / Math.PI; +} + +function fmtHour(h: number): string { + const hh = Math.floor(h) % 24; + const mm = Math.round((h - Math.floor(h)) * 60); + return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`; +} + +interface Props { + destinations: Destination[]; +} + +export default function DaylightWorkHours({ destinations }: Props) { + const [slug, setSlug] = useState(destinations[0]?.slug || "chiangmai"); + const [month, setMonth] = useState(new Date().getMonth() + 1); + + const meta = CITY_META[slug] || { lat: 20, tip: "选择城市查看日照与办公建议" }; + const dayLen = useMemo(() => approxDayLength(meta.lat, month), [meta.lat, month]); + const sunrise = 12 - dayLen / 2; + const sunset = 12 + dayLen / 2; + const deepStart = Math.max(sunrise + 1.5, 8); + const deepEnd = Math.min(sunset - 1, 17); + const outdoorOk = dayLen >= 11; + + const months = Array.from({ length: 12 }, (_, i) => i + 1); + const dest = destinations.find((d) => d.slug === slug); + + const score = + dayLen >= 13 ? { label: "长日照", color: "#FFE66D" } : + dayLen >= 11 ? { label: "均衡", color: "#4ECDC4" } : + { label: "短日照", color: "#A78BFA" }; + + return ( +
+
+
+ ☀️ DAYLIGHT +

日照办公窗

+

按城市与月份估算日出日落,安排深度工作与户外时间

+
+ +
+
+ +
+ {months.map((m) => ( + + ))} +
+
+ +
+
+
+
+
+ {[0, 6, 12, 18, 24].map((h) => ( + {fmtHour(h)} + ))} +
+
+ +
+
+ 日出 + {fmtHour(sunrise)} +
+
+ 日落 + {fmtHour(sunset)} +
+
+ 昼长 + {dayLen.toFixed(1)}h +
+
+ 状态 + {score.label} +
+
+
+ +
+
+ 💻 +
+ 深度工作建议 +

{fmtHour(deepStart)} – {fmtHour(deepEnd)} · 自然光充足且会议较少的时段

+
+
+
+ {outdoorOk ? "🚶" : "🏠"} +
+ {outdoorOk ? "适合户外散步会议" : "偏室内节奏"} +

{meta.tip}{dest ? ` · ${dest.emoji} ${dest.name}` : ""}

+
+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/HomeClient.tsx b/frontend/src/components/HomeClient.tsx index 5595693..77bc88f 100644 --- a/frontend/src/components/HomeClient.tsx +++ b/frontend/src/components/HomeClient.tsx @@ -42,6 +42,7 @@ const TaxDayCounter = lazy(() => import("./TaxDayCounter")); const FlightCost = lazy(() => import("./FlightCost")); const CurrencyConverter = lazy(() => import("./CurrencyConverter")); const MeetingWindow = lazy(() => import("./MeetingWindow")); +const DaylightWorkHours = lazy(() => import("./DaylightWorkHours")); const WifiWorkScore = lazy(() => import("./WifiWorkScore")); const FocusTimer = lazy(() => import("./FocusTimer")); const NomadScore = lazy(() => import("./NomadScore")); @@ -59,6 +60,7 @@ const PowerPlugGuide = lazy(() => import("./PowerPlugGuide")); const CafeGuide = lazy(() => import("./CafeGuide")); const SeasonGuide = lazy(() => import("./SeasonGuide")); const PackingChecklist = lazy(() => import("./PackingChecklist")); +const BagWeightEstimator = lazy(() => import("./BagWeightEstimator")); const ArrivalChecklist = lazy(() => import("./ArrivalChecklist")); const Phrasebook = lazy(() => import("./Phrasebook")); const CityNotes = lazy(() => import("./CityNotes")); @@ -173,6 +175,7 @@ export default function HomeClient(props: Props) { + @@ -190,6 +193,7 @@ export default function HomeClient(props: Props) { + diff --git a/frontend/src/components/ToolsStrip.tsx b/frontend/src/components/ToolsStrip.tsx index 56ea1a2..2444c55 100644 --- a/frontend/src/components/ToolsStrip.tsx +++ b/frontend/src/components/ToolsStrip.tsx @@ -4,12 +4,12 @@ import Link from "next/link"; const QUICK = [ { href: "/#runway", emoji: "🚀", label: "跑道" }, + { href: "/#daylight", emoji: "☀️", label: "日照" }, { href: "/#meetings", emoji: "🤝", label: "会议" }, + { href: "/#bag-weight", emoji: "⚖️", label: "行李" }, { href: "/#trip", emoji: "🗓️", label: "行程" }, { href: "/#calculator", emoji: "🧮", label: "费用" }, - { href: "/#visa", emoji: "📋", label: "签证" }, { href: "/#focus", emoji: "⏱️", label: "专注" }, - { href: "/#currency", emoji: "💱", label: "汇率" }, { href: "/tools", emoji: "🛠️", label: "全部" }, ]; diff --git a/frontend/src/lib/tools.ts b/frontend/src/lib/tools.ts index 8d5ef32..0202f88 100644 --- a/frontend/src/lib/tools.ts +++ b/frontend/src/lib/tools.ts @@ -12,6 +12,7 @@ export const TOOL_LINKS: ToolLink[] = [ { id: "spin", emoji: "🎲", title: "命运转盘", desc: "随机抽下一站", href: "/#spin", category: "plan" }, { id: "matcher", emoji: "🎯", title: "智能匹配", desc: "问卷推荐目的地", href: "/#destinations", category: "plan" }, { id: "season", emoji: "🌤️", title: "最佳月份", desc: "避开雨季与旺季", href: "/#season", category: "plan" }, + { id: "daylight", emoji: "☀️", title: "日照办公窗", desc: "日出日落与深度工作", href: "/#daylight", category: "work" }, { id: "calculator", emoji: "🧮", title: "费用计算器", desc: "按住宿档次估算", href: "/#calculator", category: "money" }, { id: "cost-compare", emoji: "💰", title: "省钱对比", desc: "家乡 vs 旅居成本", href: "/#cost-compare", category: "money" }, { id: "savings", emoji: "🎯", title: "启动金目标", desc: "存款进度追踪", href: "/#savings", category: "money" }, @@ -32,6 +33,7 @@ export const TOOL_LINKS: ToolLink[] = [ { id: "sim", emoji: "📱", title: "上网方案", desc: "SIM / eSIM 推荐", href: "/#sim", category: "life" }, { id: "plugs", emoji: "🔌", title: "电源插座", desc: "电压与插头类型", href: "/#plugs", category: "life" }, { id: "packing", emoji: "🧳", title: "行李清单", desc: "出发前逐项勾选", href: "/#packing", category: "life" }, + { id: "bag-weight", emoji: "⚖️", title: "行李重量", desc: "对照航司限额估算", href: "/#bag-weight", category: "life" }, { id: "arrival", emoji: "🛬", title: "到站清单", desc: "落地第一周", href: "/#arrival", category: "life" }, { id: "phrases", emoji: "🗣️", title: "生存短语", desc: "常用语一键复制", href: "/#phrases", category: "life" }, { id: "notes", emoji: "📝", title: "城市笔记", desc: "本地私密记录", href: "/#notes", category: "life" },