diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..dd8449a --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,19 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "frontend (Vite)", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "cwd": "frontend", + "port": 5173 + }, + { + "name": "backend (Go API)", + "runtimeExecutable": "go", + "runtimeArgs": ["run", "./cmd/server/main.go"], + "cwd": "backend", + "port": 8080 + } + ] +} diff --git a/.claude/skills/changelog/SKILL.md b/.claude/skills/changelog/SKILL.md new file mode 100644 index 0000000..1473b19 --- /dev/null +++ b/.claude/skills/changelog/SKILL.md @@ -0,0 +1,74 @@ +--- +name: changelog +description: Draft the next release's bilingual (zh/en) entry in frontend/src/changelog.json from git commits since the last release tag. Use whenever the user wants to generate or update the changelog, write release notes, prepare a release, or mentions changelog.json, 更新日志, 发版说明 — and especially before running npm run release, which requires the entry to already be committed. +--- + +# CatHeadTab Changelog Generator + +Generate the next version's entry in `frontend/src/changelog.json` from commits since the last release tag. + +This file is the single source of truth for both the in-app "About / What's New" panel and the GitHub release notes (see `frontend/scripts/release.mjs`), and it ships inside the build — so the entry must exist and be committed **before** `npm run release` runs. The release script only warns if the entry is missing; it never writes one. + +## Workflow + +Run all git commands from the repo root. + +1. **Find the last release tag**: `git describe --tags --abbrev=0` (tags look like `v1.0.2`). + +2. **Collect commits since then**: `git log ..HEAD --no-merges --pretty=format:"%s"`. If there are no commits, tell the user there is nothing to put in the changelog and stop. + +3. **Decide the version number**: + - If the user passed one (e.g. `/changelog 1.0.3` or `/changelog minor`), use it. + - Otherwise default to a **patch** bump over the last tag — this repo bumps patch even for ordinary feature batches (1.0.1 shipped several new features). State the chosen version so the user can override. + +4. **Select user-facing changes**. Keep `feat`, `fix`, and `perf` commits, plus anything else a user would notice. Drop `chore`, `docs`, `ci`, `build`, `test`, and internal refactors — the changelog reader is an end user of a browser new-tab page, not a developer. Merge commits that belong to one feature into a single bullet. + +5. **Write the bullets** following the style guide below. Order: new features first, then improvements, then fixes — matching existing entries, which lead with 新增. + +6. **Edit the JSON**: prepend an object to the array in `frontend/src/changelog.json`, matching the existing 2-space indentation: + + ```json + { + "version": "", + "date": "", + "zh": ["…"], + "en": ["…"] + } + ``` + +7. **Validate** that the file still parses: + `node -e "JSON.parse(require('fs').readFileSync('frontend/src/changelog.json','utf8'))"` + +8. **Show the entry to the user and wait for approval.** The draft is a starting point — the user knows which changes matter most to their users. After approval, commit only this file as `docs(changelog): add entry`, then remind them the release itself is `npm run release` from `frontend/` (it requires a clean working tree, which is why the changelog commit comes first). + +## Bullet style guide + +Each bullet describes what the **user** gains, not what the code does. Translate from implementation language to product language. The zh list is the primary text; en mirrors it naturally rather than word-for-word. + +- zh bullets start with a verb: 新增 / 修复 / 优化 / 改进 / 增强 / 支持 +- en bullets start with a past-tense verb: Added / Fixed / Improved / Enhanced +- No trailing punctuation; keep each bullet to one line +- Name the feature area the way the UI does (日历, 便签, 设置), not by component or file name + +**Example:** + +Input commits: +``` +feat(calendar): jump to date from festival list and add countdown shortcut +feat(calendar): quick year/month picker in calendar detail modal +fix(calendar): keep month festival list stable when jumping to a festival +``` + +Output bullets: +``` +zh: "日历详情页支持点击右侧节日跳转到对应日期" +zh: "日历详情页新增年份与月份快速选择" +zh: "新增一键添加倒计时小组件:选中日期后即可在桌面创建倒计时" +zh: "修复本月节日列表在点击节日后条目消失的问题" +en: "Calendar detail: click a festival in the sidebar to jump to its date" +en: "Calendar detail: added a quick year/month picker" +en: "Added one-click countdown widgets: pick a date and create a countdown on the desktop" +en: "Fixed the monthly festival list losing entries after clicking a festival" +``` + +Note how three `feat` commits became four bullets — split or merge by what reads clearly to a user, not by commit boundaries. diff --git a/frontend/src/changelog.json b/frontend/src/changelog.json index 99d48a9..2a4c11d 100644 --- a/frontend/src/changelog.json +++ b/frontend/src/changelog.json @@ -1,4 +1,20 @@ [ + { + "version": "1.0.3", + "date": "2026-06-11", + "zh": [ + "日历详情页支持点击右侧节日跳转到对应日期", + "日历详情页新增年份与月份快速选择", + "新增一键添加倒计时小组件:选中日期后即可在桌面创建倒计时", + "修复本月节日列表在点击节日后条目消失的问题" + ], + "en": [ + "Calendar detail: click a festival in the sidebar to jump to its date", + "Calendar detail: added a quick year/month picker", + "Added one-click countdown widgets: pick a date and create a countdown on the desktop", + "Fixed the monthly festival list losing entries after clicking a festival" + ] + }, { "version": "1.0.2", "date": "2026-06-10", diff --git a/frontend/src/components/CalendarDetailModal.tsx b/frontend/src/components/CalendarDetailModal.tsx index 155bd9b..b5b64d3 100644 --- a/frontend/src/components/CalendarDetailModal.tsx +++ b/frontend/src/components/CalendarDetailModal.tsx @@ -1,7 +1,8 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; import { useFloatingWindow } from '../hooks/useFloatingWindow'; import { useTranslation } from '../i18n/useTranslation'; +import { useLayoutStore } from '../store/layoutStore'; import { buildCalendarMonth, formatLunarDate, @@ -11,6 +12,8 @@ import { getUpcomingCalendarEvents, isSameDate, solarToLunar, + MAX_CALENDAR_YEAR, + MIN_CALENDAR_YEAR, WEEK_DAYS_EN, WEEK_DAYS_ZH, type CalendarFestival, @@ -23,6 +26,9 @@ interface CalendarDetailModalProps { onEdit?: () => void; } +const MONTHS_ZH = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; +const MONTHS_EN = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const CATEGORY_TONE: Record = { chinese: 'border-red-300/25 bg-red-500/15 text-red-100', international: 'border-sky-300/25 bg-sky-400/15 text-sky-100', @@ -72,6 +78,32 @@ function TodayIcon() { ); } +function ChevronDownIcon({ open }: { open: boolean }) { + return ( + + + + ); +} + +function TimerIcon() { + return ( + + + + + + ); +} + +function CheckIcon() { + return ( + + + + ); +} + function getFestivalName(item: CalendarFestival, isZh: boolean): string { return isZh ? item.nameZh : item.nameEn; } @@ -101,6 +133,8 @@ function formatCompactDate(date: Date, isZh: boolean): string { function formatDaysAway(daysAway: number, isZh: boolean): string { if (daysAway === 0) return isZh ? '今天' : 'Today'; if (daysAway === 1) return isZh ? '明天' : 'Tomorrow'; + if (daysAway === -1) return isZh ? '昨天' : 'Yesterday'; + if (daysAway < 0) return isZh ? `${-daysAway}天前` : `${-daysAway} days ago`; return isZh ? `${daysAway}天后` : `In ${daysAway} days`; } @@ -117,10 +151,12 @@ function EventList({ emptyText, events, isZh, + onSelectDate, }: { emptyText: string; events: UpcomingCalendarEvent[]; isZh: boolean; + onSelectDate: (date: Date) => void; }) { if (events.length === 0) { return ( @@ -133,9 +169,12 @@ function EventList({ return (
{events.map((event) => ( -
onSelectDate(event.date)} + title={isZh ? '跳转到该日期' : 'Jump to this date'} + className="flex w-full min-w-0 items-center gap-3 rounded-xl border border-white/[0.06] bg-white/[0.035] px-3 py-2.5 text-left transition-colors hover:border-white/15 hover:bg-white/[0.07]" >
{event.date.getDate()} @@ -153,7 +192,7 @@ function EventList({ {formatDaysAway(event.daysAway, isZh)}
-
+ ))} ); @@ -194,36 +233,81 @@ export const CalendarDetailModal: React.FC = ({ onClos getFestivalsForDate(selectedDate, selectedLunar) ), [selectedDate, selectedLunar]); + // Days-away labels are always relative to the real today, not the selected + // date — a festival should only ever read "今天" when it actually is today. + const todayStart = useMemo(() => ( + new Date(today.getFullYear(), today.getMonth(), today.getDate()) + ), [today]); + + const daysFromToday = useCallback((date: Date) => ( + Math.round((date.getTime() - todayStart.getTime()) / 86400000) + ), [todayStart]); + const upcomingEvents = useMemo(() => ( getUpcomingCalendarEvents(selectedDate, 12, 140) - ), [selectedDate]); + .map((event) => ({ ...event, daysAway: daysFromToday(event.date) })) + ), [selectedDate, daysFromToday]); const upcomingInternationalEvents = useMemo(() => ( getUpcomingCalendarEvents(selectedDate, 30, 220) .filter((event) => event.festival.category === 'international') .slice(0, 5) - ), [selectedDate]); + .map((event) => ({ ...event, daysAway: daysFromToday(event.date) })) + ), [selectedDate, daysFromToday]); - const monthEvents = useMemo(() => { - const selectedStart = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate()); - return calendarDays - .filter((day) => day.isCurrentMonth && day.date >= selectedStart && day.festivals.length > 0) + const monthEvents = useMemo(() => ( + calendarDays + .filter((day) => day.isCurrentMonth && day.festivals.length > 0) .flatMap((day) => day.festivals.map((item) => ({ date: day.date, key: `${day.key}-${item.id}`, festival: item, - daysAway: Math.round((day.date.getTime() - selectedStart.getTime()) / 86400000), + daysAway: daysFromToday(day.date), }))) - .slice(0, 10); - }, [calendarDays, selectedDate]); + .slice(0, 10) + ), [calendarDays, daysFromToday]); + + // Year/month quick picker state + const [pickerOpen, setPickerOpen] = useState(false); + const [pickerMode, setPickerMode] = useState<'month' | 'year'>('month'); + const [pickerYear, setPickerYear] = useState(viewYear); + const pickerRef = useRef(null); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') onClose(); + if (event.key !== 'Escape') return; + if (pickerOpen) { + setPickerOpen(false); + return; + } + onClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [onClose]); + }, [onClose, pickerOpen]); + + // Close picker on outside click. Capture phase, because the modal shell + // stops mousedown propagation so a bubble-phase document listener never fires. + useEffect(() => { + if (!pickerOpen) return; + const handler = (event: MouseEvent) => { + if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) { + setPickerOpen(false); + } + }; + document.addEventListener('mousedown', handler, true); + return () => document.removeEventListener('mousedown', handler, true); + }, [pickerOpen]); + + const togglePicker = useCallback(() => { + setPickerOpen((open) => { + if (!open) { + setPickerYear(viewYear); + setPickerMode('month'); + } + return !open; + }); + }, [viewYear]); const selectDate = useCallback((date: Date) => { setSelectedDate(date); @@ -248,6 +332,41 @@ export const CalendarDetailModal: React.FC = ({ onClos setSelectedDate(today); }, [today]); + // Jump straight to a year+month from the quick picker, keeping the + // selected day-of-month clamped like shiftMonth does. + const selectYearMonth = useCallback((year: number, monthIndex: number) => { + const maxDay = new Date(year, monthIndex + 1, 0).getDate(); + setViewYear(year); + setViewMonth(monthIndex); + setSelectedDate(new Date(year, monthIndex, Math.min(selectedDate.getDate(), maxDay))); + setPickerOpen(false); + }, [selectedDate]); + + const addWidget = useLayoutStore((state) => state.addWidget); + const [countdownAdded, setCountdownAdded] = useState(false); + + const addCountdownWidget = useCallback(() => { + const eventName = selectedFestivals.length > 0 + ? getFestivalName(selectedFestivals[0], isZh) + : formatCompactDate(selectedDate, isZh); + addWidget('countdown', 'medium', { + widgetType: 'countdown', + targetDate: getDateKey(selectedDate), + eventName, + }); + setCountdownAdded(true); + }, [addWidget, isZh, selectedDate, selectedFestivals]); + + useEffect(() => { + setCountdownAdded(false); + }, [selectedDate]); + + useEffect(() => { + if (!countdownAdded) return; + const timer = setTimeout(() => setCountdownAdded(false), 2000); + return () => clearTimeout(timer); + }, [countdownAdded]); + const modal = (
= ({ onClos

{selectedDate.getDate()}

- {formatMonthTitle(viewYear, viewMonth, isZh)} +
+ + {pickerOpen && ( +
+
+ + + +
+ {pickerMode === 'month' ? ( +
+ {(isZh ? MONTHS_ZH : MONTHS_EN).map((label, idx) => { + const isCurrent = pickerYear === viewYear && idx === viewMonth; + const isThisMonth = pickerYear === today.getFullYear() && idx === today.getMonth(); + return ( + + ); + })} +
+ ) : ( +
+ {Array.from({ length: 12 }, (_, i) => Math.floor(pickerYear / 12) * 12 + i).map((year) => { + const outOfRange = year < MIN_CALENDAR_YEAR || year > MAX_CALENDAR_YEAR; + const isCurrent = year === viewYear; + const isThisYear = year === today.getFullYear(); + return ( + + ); + })} +
+ )} +
+ )} +
{formatLunarDate(selectedLunar, isZh)}
@@ -432,6 +654,21 @@ export const CalendarDetailModal: React.FC = ({ onClos {isZh ? '暂无节日或节气' : 'No festival or solar term'} )} + @@ -444,6 +681,7 @@ export const CalendarDetailModal: React.FC = ({ onClos emptyText={isZh ? '本月暂无已标记节日' : 'No marked events this month'} events={monthEvents} isZh={isZh} + onSelectDate={selectDate} /> @@ -456,6 +694,7 @@ export const CalendarDetailModal: React.FC = ({ onClos emptyText={isZh ? '近期暂无节日' : 'No upcoming events'} events={upcomingEvents} isZh={isZh} + onSelectDate={selectDate} /> @@ -468,6 +707,7 @@ export const CalendarDetailModal: React.FC = ({ onClos emptyText={isZh ? '近期暂无国外节日' : 'No international holidays soon'} events={upcomingInternationalEvents} isZh={isZh} + onSelectDate={selectDate} /> diff --git a/frontend/src/utils/calendar.ts b/frontend/src/utils/calendar.ts index 47e3478..b852938 100644 --- a/frontend/src/utils/calendar.ts +++ b/frontend/src/utils/calendar.ts @@ -23,6 +23,10 @@ const LUNAR_INFO = [ ]; const MAX_LUNAR_YEAR = MIN_LUNAR_YEAR + LUNAR_INFO.length - 1; + +/** Year range with lunar data available — calendar UI should not navigate outside it. */ +export const MIN_CALENDAR_YEAR = MIN_LUNAR_YEAR; +export const MAX_CALENDAR_YEAR = MAX_LUNAR_YEAR; const LUNAR_MONTHS = ['正', '二', '三', '四', '五', '六', '七', '八', '九', '十', '冬', '腊']; const LUNAR_DAYS = [ '初一','初二','初三','初四','初五','初六','初七','初八','初九','初十',