This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
"""Pure lunar-calendar conversion helpers for countdowns.
|
||||
|
||||
Lunar months use lunar-python's convention: 1..12 are regular months and
|
||||
-1..-12 are leap months. A missing leap month or invalid lunar day returns None.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from lunar_python import Lunar, Solar
|
||||
|
||||
|
||||
def lunar_to_solar_safe(year: int, month: int, day: int) -> date | None:
|
||||
"""Convert a lunar date without allowing library exceptions to escape."""
|
||||
try:
|
||||
solar = Lunar.fromYmd(year, month, day).getSolar()
|
||||
result = date(solar.getYear(), solar.getMonth(), solar.getDay())
|
||||
lunar = solar.getLunar()
|
||||
if (lunar.getYear(), lunar.getMonth(), lunar.getDay()) != (year, month, day):
|
||||
return None
|
||||
return result
|
||||
except Exception: # noqa: BLE001 - library raises plain Exception for invalid leap months.
|
||||
return None
|
||||
|
||||
|
||||
def _lunar_date(value: date):
|
||||
return Solar.fromYmd(value.year, value.month, value.day).getLunar()
|
||||
|
||||
|
||||
def solar_to_lunar_parts(value: date) -> tuple[int, int, int]:
|
||||
lunar = _lunar_date(value)
|
||||
return lunar.getYear(), lunar.getMonth(), lunar.getDay()
|
||||
|
||||
|
||||
def solar_to_lunar_text(value: date) -> str:
|
||||
lunar = _lunar_date(value)
|
||||
return f"农历{lunar.getMonthInChinese()}月{lunar.getDayInChinese()}"
|
||||
|
||||
|
||||
def lunar_label_with_year(value: date) -> str:
|
||||
lunar = _lunar_date(value)
|
||||
return f"农历{lunar.getYearInChinese()}年{lunar.getMonthInChinese()}月{lunar.getDayInChinese()}"
|
||||
|
||||
|
||||
def next_lunar_occurrence(
|
||||
month: int,
|
||||
day: int,
|
||||
ignore_year: bool,
|
||||
repeat_rule: str,
|
||||
today: date,
|
||||
) -> date | None:
|
||||
"""Find the next matching lunar month/day, including sparse leap months."""
|
||||
del ignore_year, repeat_rule # Both make lunar month/day recur by lunar year.
|
||||
# Leap months can be separated by more than a decade. The library supports a
|
||||
# bounded year range, so search far enough for all practical countdowns.
|
||||
for year in range(today.year - 1, today.year + 101):
|
||||
candidate = lunar_to_solar_safe(year, month, day)
|
||||
if candidate is not None and candidate >= today:
|
||||
return candidate
|
||||
return None
|
||||
@@ -135,6 +135,10 @@ class Countdown(Base):
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
title: Mapped[str] = mapped_column(String(200))
|
||||
event_date: Mapped[date] = mapped_column(Date)
|
||||
calendar_mode: Mapped[str] = mapped_column(String(8), default="solar")
|
||||
lunar_month: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
lunar_day: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
ignore_year: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
kind: Mapped[str] = mapped_column(String(16), default="countdown")
|
||||
repeat_rule: Mapped[str] = mapped_column(String(16), default="none")
|
||||
icon: Mapped[str] = mapped_column(String(32), default="📅")
|
||||
|
||||
+91
-3
@@ -16,6 +16,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from .auth import current_user
|
||||
from .config import get_settings
|
||||
from .db import get_db
|
||||
from .lunar_support import (
|
||||
lunar_label_with_year,
|
||||
lunar_to_solar_safe,
|
||||
next_lunar_occurrence,
|
||||
solar_to_lunar_parts,
|
||||
solar_to_lunar_text,
|
||||
)
|
||||
from .models import (
|
||||
Attachment,
|
||||
AuditLog,
|
||||
@@ -290,15 +297,41 @@ def countdown_status(display_date: date, today: date) -> tuple[int, str]:
|
||||
class CountdownInput(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
event_date: date
|
||||
calendar_mode: str = Field("solar", pattern="^(solar|lunar)$")
|
||||
lunar_month: int | None = None
|
||||
lunar_day: int | None = None
|
||||
ignore_year: bool = False
|
||||
kind: str = Field("countdown", pattern="^(countdown|anniversary|birthday)$")
|
||||
repeat_rule: str = Field("none", pattern="^(none|weekly|monthly|yearly)$")
|
||||
icon: str = Field("📅", min_length=1, max_length=32)
|
||||
pinned: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def calendar_fields_valid(self):
|
||||
if self.calendar_mode == "solar":
|
||||
if self.lunar_month is not None or self.lunar_day is not None:
|
||||
raise ValueError("solar countdown cannot include lunar fields")
|
||||
return self
|
||||
if self.lunar_month is None or self.lunar_day is None:
|
||||
raise ValueError("lunar_month and lunar_day are required")
|
||||
if self.lunar_month == 0 or not -12 <= self.lunar_month <= 12:
|
||||
raise ValueError("lunar_month must be 1..12 or -1..-12 for leap months")
|
||||
if not 1 <= self.lunar_day <= 30:
|
||||
raise ValueError("lunar_day must be 1..30")
|
||||
converted = lunar_to_solar_safe(self.event_date.year, self.lunar_month, self.lunar_day)
|
||||
if converted is None:
|
||||
raise ValueError("lunar date does not exist in the selected year")
|
||||
self.event_date = converted
|
||||
return self
|
||||
|
||||
|
||||
class CountdownUpdate(BaseModel):
|
||||
title: str | None = Field(None, min_length=1, max_length=200)
|
||||
event_date: date | None = None
|
||||
calendar_mode: str | None = Field(None, pattern="^(solar|lunar)$")
|
||||
lunar_month: int | None = None
|
||||
lunar_day: int | None = None
|
||||
ignore_year: bool | None = None
|
||||
kind: str | None = Field(None, pattern="^(countdown|anniversary|birthday)$")
|
||||
repeat_rule: str | None = Field(None, pattern="^(none|weekly|monthly|yearly)$")
|
||||
icon: str | None = Field(None, min_length=1, max_length=32)
|
||||
@@ -308,18 +341,43 @@ class CountdownUpdate(BaseModel):
|
||||
for field in self.model_fields_set:
|
||||
if getattr(self, field) is None:
|
||||
raise ValueError(f"{field} cannot be null")
|
||||
if self.lunar_month is not None and (self.lunar_month == 0 or not -12 <= self.lunar_month <= 12):
|
||||
raise ValueError("lunar_month must be 1..12 or -1..-12 for leap months")
|
||||
if self.lunar_day is not None and not 1 <= self.lunar_day <= 30:
|
||||
raise ValueError("lunar_day must be 1..30")
|
||||
return self
|
||||
|
||||
|
||||
def countdown_dict(row: Countdown, today: date | None = None):
|
||||
today = today or datetime.now(ZoneInfo("Asia/Shanghai")).date()
|
||||
display_date = countdown_occurrence(row.event_date, row.repeat_rule, today)
|
||||
if row.calendar_mode == "lunar" and (row.ignore_year or row.repeat_rule != "none"):
|
||||
display_date = next_lunar_occurrence(
|
||||
row.lunar_month, row.lunar_day, row.ignore_year, row.repeat_rule, today
|
||||
) or row.event_date
|
||||
else:
|
||||
repeat_rule = "yearly" if row.ignore_year else row.repeat_rule
|
||||
display_date = countdown_occurrence(row.event_date, repeat_rule, today)
|
||||
days, day_text = countdown_status(display_date, today)
|
||||
lunar_year = None
|
||||
lunar_text = None
|
||||
if row.calendar_mode == "lunar":
|
||||
lunar_year, _, _ = solar_to_lunar_parts(row.event_date)
|
||||
lunar_text = (
|
||||
solar_to_lunar_text(display_date)
|
||||
if row.ignore_year or row.repeat_rule != "none"
|
||||
else lunar_label_with_year(row.event_date)
|
||||
)
|
||||
return {
|
||||
"id": row.id,
|
||||
"title": row.title,
|
||||
"event_date": row.event_date,
|
||||
"display_date": display_date,
|
||||
"calendar_mode": row.calendar_mode,
|
||||
"lunar_year": lunar_year,
|
||||
"lunar_month": row.lunar_month,
|
||||
"lunar_day": row.lunar_day,
|
||||
"ignore_year": row.ignore_year,
|
||||
"lunar_text": lunar_text,
|
||||
"kind": row.kind,
|
||||
"repeat_rule": row.repeat_rule,
|
||||
"icon": row.icon,
|
||||
@@ -367,7 +425,30 @@ async def list_countdowns(archived: bool = False, user: User = Depends(current_u
|
||||
@router.patch("/countdowns/{countdown_id}")
|
||||
async def edit_countdown(countdown_id: UUID, payload: CountdownUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await owned_countdown(db, user.id, countdown_id)
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
values = payload.model_dump(exclude_unset=True)
|
||||
combined = {
|
||||
"event_date": values.get("event_date", row.event_date),
|
||||
"calendar_mode": values.get("calendar_mode", row.calendar_mode),
|
||||
"lunar_month": values.get("lunar_month", row.lunar_month),
|
||||
"lunar_day": values.get("lunar_day", row.lunar_day),
|
||||
"ignore_year": values.get("ignore_year", row.ignore_year),
|
||||
}
|
||||
if combined["calendar_mode"] == "solar":
|
||||
if combined["lunar_month"] is not None or combined["lunar_day"] is not None:
|
||||
if "calendar_mode" not in values:
|
||||
raise HTTPException(422, "公历倒数日不能设置农历日期")
|
||||
combined["lunar_month"] = combined["lunar_day"] = None
|
||||
else:
|
||||
if combined["lunar_month"] is None or combined["lunar_day"] is None:
|
||||
raise HTTPException(422, "农历倒数日需要月份和日期")
|
||||
converted = lunar_to_solar_safe(
|
||||
combined["event_date"].year, combined["lunar_month"], combined["lunar_day"]
|
||||
)
|
||||
if converted is None:
|
||||
raise HTTPException(422, "所选年份不存在该农历日期")
|
||||
combined["event_date"] = converted
|
||||
values.update(combined)
|
||||
for key, value in values.items():
|
||||
setattr(row, key, value)
|
||||
row.updated_at = utcnow()
|
||||
audit(db, user.id, "update", "countdown", row.id)
|
||||
@@ -672,7 +753,7 @@ async def export_json(user: User = Depends(current_user), db: AsyncSession = Dep
|
||||
"lists": [serialize(x, ["id", "folder_id", "name", "is_inbox", "position", "deleted_at"]) for x in lists],
|
||||
"tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "due_at", "external_id", "deleted_at"]) for x in tasks],
|
||||
"habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at"]) for x in habits],
|
||||
"countdowns": [serialize(x, ["id", "title", "event_date", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns],
|
||||
"countdowns": [serialize(x, ["id", "title", "event_date", "calendar_mode", "lunar_month", "lunar_day", "ignore_year", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns],
|
||||
}
|
||||
|
||||
|
||||
@@ -766,11 +847,18 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
|
||||
item = CountdownInput(
|
||||
title=raw["title"],
|
||||
event_date=date.fromisoformat(raw["event_date"]),
|
||||
calendar_mode=raw.get("calendar_mode", "solar"),
|
||||
lunar_month=raw.get("lunar_month"),
|
||||
lunar_day=raw.get("lunar_day"),
|
||||
ignore_year=bool(raw.get("ignore_year", False)),
|
||||
kind=raw.get("kind", "countdown"),
|
||||
repeat_rule=raw.get("repeat_rule", "none"),
|
||||
icon=raw.get("icon", "📅"),
|
||||
pinned=bool(raw.get("pinned", False)) and not has_pinned_countdown,
|
||||
)
|
||||
# Backups store the canonical solar anchor; validation above converts
|
||||
# lunar input again, so preserve the exact exported anchor on restore.
|
||||
item.event_date = date.fromisoformat(raw["event_date"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise HTTPException(422, "无效的倒数日备份数据") from exc
|
||||
row = Countdown(
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-2NrQsU0j.js"></script>
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-Ds2Y4RQ7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DQ5ZvkC6.css">
|
||||
</head><body><div id="app"></div></body></html>
|
||||
@@ -2,103 +2,89 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Archive, ArchiveRestore, CalendarHeart, Pencil, Pin, Plus, Trash2, X } from 'lucide-vue-next'
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
import { countdownDayText, countdownKindLabel, dateKey } from './lib/mvp-utils'
|
||||
import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey } from './lib/mvp-utils'
|
||||
|
||||
type Countdown = {
|
||||
id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday'
|
||||
repeat_rule: 'none'|'weekly'|'monthly'|'yearly'; icon: string; pinned: boolean; archived_at: string|null; days: number
|
||||
calendar_mode: 'solar'|'lunar'; lunar_year: number|null; lunar_month: number|null; lunar_day: number|null
|
||||
ignore_year: boolean; lunar_text: string|null
|
||||
}
|
||||
type Form = { title:string; event_date:string; kind:Countdown['kind']; repeat_rule:Countdown['repeat_rule']; icon:string; calendar_mode:Countdown['calendar_mode']; lunar_year:number; lunar_month:number; lunar_day:number; leap_month:boolean; ignore_year:boolean }
|
||||
const emit = defineEmits<{ notice: [message: string] }>()
|
||||
const items = ref<Countdown[]>([])
|
||||
const archived = ref<Countdown[]>([])
|
||||
const showArchived = ref(false)
|
||||
const open = ref(false)
|
||||
const editingId = ref<string|null>(null)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({ title: '', event_date: dateKey(new Date()), kind: 'countdown' as Countdown['kind'], repeat_rule: 'none' as Countdown['repeat_rule'], icon: '📅' })
|
||||
const items = ref<Countdown[]>([]), archived = ref<Countdown[]>([])
|
||||
const showArchived = ref(false), open = ref(false), busy = ref(false)
|
||||
const editingId = ref<string|null>(null), error = ref('')
|
||||
const currentYear = new Date().getFullYear()
|
||||
const freshForm = (): Form => ({ title:'', event_date:dateKey(new Date()), kind:'countdown', repeat_rule:'none', icon:'📅', calendar_mode:'solar', lunar_year:currentYear, lunar_month:1, lunar_day:1, leap_month:false, ignore_year:false })
|
||||
const form = ref<Form>(freshForm())
|
||||
|
||||
async function request(path: string, options: RequestInit = {}) {
|
||||
const headers: Record<string,string> = { ...(options.headers as Record<string,string> || {}) }
|
||||
async function request(path:string, options:RequestInit={}) {
|
||||
const headers:Record<string,string> = { ...(options.headers as Record<string,string> || {}) }
|
||||
if (options.body) headers['Content-Type'] = 'application/json'
|
||||
Object.assign(headers, csrfHeader(options.method))
|
||||
const response = await fetch('/api/v1' + path, { credentials: 'include', ...options, headers })
|
||||
const response = await fetch('/api/v1' + path, { credentials:'include', ...options, headers })
|
||||
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.detail || '请求失败') }
|
||||
return response.status === 204 ? null : response.json()
|
||||
}
|
||||
async function safe(work: () => Promise<void>) {
|
||||
busy.value = true; error.value = ''
|
||||
try { await work() } catch (reason) { error.value = reason instanceof Error ? reason.message : '请求失败' } finally { busy.value = false }
|
||||
}
|
||||
async function load() {
|
||||
await safe(async () => {
|
||||
const [activeRows, archivedRows] = await Promise.all([request('/countdowns'), request('/countdowns?archived=true')])
|
||||
items.value = activeRows; archived.value = archivedRows
|
||||
})
|
||||
}
|
||||
function add() {
|
||||
editingId.value = null
|
||||
form.value = { title: '', event_date: dateKey(new Date()), kind: 'countdown', repeat_rule: 'none', icon: '📅' }
|
||||
open.value = true
|
||||
}
|
||||
function edit(item: Countdown) {
|
||||
editingId.value = item.id
|
||||
form.value = { title: item.title, event_date: item.event_date, kind: item.kind, repeat_rule: item.repeat_rule, icon: item.icon }
|
||||
open.value = true
|
||||
async function safe(work:()=>Promise<void>) { busy.value=true; error.value=''; try { await work() } catch(reason) { error.value=reason instanceof Error ? reason.message : '请求失败' } finally { busy.value=false } }
|
||||
async function load() { await safe(async()=>{ const [a,b]=await Promise.all([request('/countdowns'),request('/countdowns?archived=true')]); items.value=a; archived.value=b }) }
|
||||
function add() { editingId.value=null; form.value=freshForm(); open.value=true }
|
||||
function edit(item:Countdown) {
|
||||
editingId.value=item.id
|
||||
form.value={ title:item.title, event_date:item.event_date, kind:item.kind, repeat_rule:item.repeat_rule, icon:item.icon, calendar_mode:item.calendar_mode, lunar_year:item.lunar_year || Number(item.event_date.slice(0,4)), lunar_month:Math.abs(item.lunar_month || 1), lunar_day:item.lunar_day || 1, leap_month:(item.lunar_month || 0)<0, ignore_year:item.ignore_year }
|
||||
open.value=true
|
||||
}
|
||||
async function save() {
|
||||
if (!form.value.title.trim()) return
|
||||
await safe(async () => {
|
||||
const path = editingId.value ? `/countdowns/${editingId.value}` : '/countdowns'
|
||||
await request(path, { method: editingId.value ? 'PATCH' : 'POST', body: JSON.stringify({ ...form.value, title: form.value.title.trim() }) })
|
||||
open.value = false; await load(); emit('notice', editingId.value ? '倒数日已更新' : '倒数日已添加')
|
||||
await safe(async()=>{
|
||||
const payload:any={ title:form.value.title.trim(), event_date:form.value.calendar_mode==='lunar' ? `${form.value.lunar_year}-01-01` : form.value.event_date, kind:form.value.kind, repeat_rule:form.value.repeat_rule, icon:form.value.icon, calendar_mode:form.value.calendar_mode, ignore_year:form.value.ignore_year }
|
||||
if (form.value.calendar_mode==='lunar') { payload.lunar_month=form.value.leap_month ? -form.value.lunar_month : form.value.lunar_month; payload.lunar_day=form.value.lunar_day }
|
||||
const path=editingId.value ? `/countdowns/${editingId.value}` : '/countdowns'
|
||||
await request(path,{ method:editingId.value?'PATCH':'POST', body:JSON.stringify(payload) })
|
||||
open.value=false; await load(); emit('notice',editingId.value?'倒数日已更新':'倒数日已添加')
|
||||
})
|
||||
}
|
||||
async function pin(item: Countdown) { await safe(async () => { await request(`/countdowns/${item.id}/pin`, { method: 'POST' }); await load(); emit('notice', '已置顶') }) }
|
||||
async function archiveItem(item: Countdown) { await safe(async () => { await request(`/countdowns/${item.id}`, { method: 'DELETE' }); await load(); emit('notice', '已归档') }) }
|
||||
async function restore(item: Countdown) { await safe(async () => { await request(`/countdowns/${item.id}/restore`, { method: 'POST' }); await load(); emit('notice', '已恢复') }) }
|
||||
async function purge(item: Countdown) {
|
||||
if (!confirm(`永久删除“${item.title}”?这个操作不能撤销。`)) return
|
||||
await safe(async () => { await request(`/countdowns/${item.id}/purge`, { method: 'DELETE' }); await load(); emit('notice', '已永久删除') })
|
||||
}
|
||||
function formatDate(value: string) {
|
||||
const [year, month, day] = value.split('-')
|
||||
return `${year}年${Number(month)}月${Number(day)}日`
|
||||
}
|
||||
function repeatLabel(value: Countdown['repeat_rule']) { return ({ none: '不重复', weekly: '每周', monthly: '每月', yearly: '每年' })[value] }
|
||||
async function pin(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}/pin`,{method:'POST'});await load();emit('notice','已置顶')})}
|
||||
async function archiveItem(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}`,{method:'DELETE'});await load();emit('notice','已归档')})}
|
||||
async function restore(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}/restore`,{method:'POST'});await load();emit('notice','已恢复')})}
|
||||
async function purge(item:Countdown){if(!confirm(`永久删除“${item.title}”?这个操作不能撤销。`))return;await safe(async()=>{await request(`/countdowns/${item.id}/purge`,{method:'DELETE'});await load();emit('notice','已永久删除')})}
|
||||
function formatDate(value:string){const [y,m,d]=value.split('-');return `${y}年${Number(m)}月${Number(d)}日`}
|
||||
function repeatLabel(value:Countdown['repeat_rule']){return({none:'不重复',weekly:'每周',monthly:'每月',yearly:'每年'})[value]}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="countdown-view" :class="{ loading: busy }">
|
||||
<header class="countdown-hero">
|
||||
<div><small>记住值得期待与纪念的日子</small><h2>倒数日</h2></div>
|
||||
<button class="countdown-add" @click="add"><Plus/>添加日子</button>
|
||||
</header>
|
||||
<section class="countdown-view" :class="{ loading:busy }">
|
||||
<header class="countdown-hero"><div><small>记住值得期待与纪念的日子</small><h2>倒数日</h2></div><button class="countdown-add" @click="add"><Plus/>添加日子</button></header>
|
||||
<p v-if="error" class="inline-error">{{error}}</p>
|
||||
<div class="countdown-grid">
|
||||
<article v-for="item in items" :key="item.id" class="countdown-card" :class="{ pinned:item.pinned }">
|
||||
<div class="countdown-card-head"><span class="countdown-icon">{{item.icon}}</span><span class="countdown-type">{{countdownKindLabel(item.kind)}}</span><Pin v-if="item.pinned" class="pinned-icon"/></div>
|
||||
<h3>{{item.title}}</h3>
|
||||
<div class="countdown-number"><strong>{{Math.abs(item.days)}}</strong><span v-if="item.days!==0">天</span></div>
|
||||
<b class="countdown-copy">{{countdownDayText(item.days)}}</b>
|
||||
<div class="countdown-card-head"><span class="countdown-icon">{{item.icon}}</span><span class="countdown-type">{{countdownKindLabel(item.kind)}}</span><span v-if="item.calendar_mode==='lunar'" class="countdown-type">农历</span><Pin v-if="item.pinned" class="pinned-icon"/></div>
|
||||
<h3>{{item.title}}</h3><div class="countdown-number"><strong>{{Math.abs(item.days)}}</strong><span v-if="item.days!==0">天</span></div><b class="countdown-copy">{{countdownDayText(item.days)}}</b>
|
||||
<p>{{formatDate(item.display_date)}}<span v-if="item.repeat_rule!=='none'"> · {{repeatLabel(item.repeat_rule)}}</span></p>
|
||||
<p v-if="item.lunar_text"><span class="countdown-type">{{item.lunar_text}}</span><span v-if="item.ignore_year"> · 每年农历</span></p>
|
||||
<div class="countdown-actions"><button v-if="!item.pinned" @click="pin(item)"><Pin/>置顶</button><button @click="edit(item)"><Pencil/>编辑</button><button @click="archiveItem(item)"><Archive/>归档</button></div>
|
||||
</article>
|
||||
<button v-if="!items.length&&!busy" class="countdown-empty" @click="add"><CalendarHeart/><b>添加第一个重要日子</b><span>生日、纪念日,或一场期待已久的旅行</span></button>
|
||||
</div>
|
||||
<button v-if="archived.length" class="archived-toggle" @click="showArchived=!showArchived"><ArchiveRestore/>已归档({{archived.length}})</button>
|
||||
<div v-if="showArchived" class="archived-countdowns"><article v-for="item in archived" :key="item.id"><span>{{item.icon}}</span><b>{{item.title}}</b><small>{{formatDate(item.event_date)}}</small><button @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" @click="purge(item)"><Trash2/>永久删除</button></article></div>
|
||||
|
||||
<div v-if="open" class="countdown-modal-mask" @click.self="open=false">
|
||||
<form class="countdown-modal" role="dialog" aria-modal="true" @submit.prevent="save">
|
||||
<header><div><small>{{editingId?'调整重要日子':'记下重要日子'}}</small><h3>{{editingId?'编辑倒数日':'新建倒数日'}}</h3></div><button type="button" aria-label="关闭" @click="open=false"><X/></button></header>
|
||||
<label>图标与名称<div class="countdown-title-fields"><input v-model="form.icon" maxlength="8" aria-label="图标"><input v-model="form.title" maxlength="200" required placeholder="例如:去北海道旅行" autofocus></div></label>
|
||||
<label>日期<input v-model="form.event_date" type="date" required></label>
|
||||
<label>类型<select v-model="form.kind"><option value="countdown">倒数日</option><option value="anniversary">纪念日</option><option value="birthday">生日</option></select></label>
|
||||
<label>重复<select v-model="form.repeat_rule"><option value="none">不重复</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option></select></label>
|
||||
<footer><button type="button" class="secondary" @click="open=false">取消</button><button class="primary-small">保存</button></footer>
|
||||
</form>
|
||||
</div>
|
||||
<div v-if="showArchived" class="archived-countdowns"><article v-for="item in archived" :key="item.id"><span>{{item.icon}}</span><b>{{item.title}}</b><small>{{formatDate(item.event_date)}}<template v-if="item.lunar_text"> · {{item.lunar_text}}</template></small><button @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" @click="purge(item)"><Trash2/>永久删除</button></article></div>
|
||||
<div v-if="open" class="countdown-modal-mask" @click.self="open=false"><form class="countdown-modal" role="dialog" aria-modal="true" @submit.prevent="save">
|
||||
<header><div><small>{{editingId?'调整重要日子':'记下重要日子'}}</small><h3>{{editingId?'编辑倒数日':'新建倒数日'}}</h3></div><button type="button" aria-label="关闭" @click="open=false"><X/></button></header>
|
||||
<label>图标与名称<div class="countdown-title-fields"><input v-model="form.icon" maxlength="8" aria-label="图标"><input v-model="form.title" maxlength="200" required placeholder="例如:去北海道旅行" autofocus></div></label>
|
||||
<label>历法<select v-model="form.calendar_mode"><option value="solar">{{calendarModeLabel('solar')}}</option><option value="lunar">{{calendarModeLabel('lunar')}}</option></select></label>
|
||||
<label v-if="form.calendar_mode==='solar'">日期<input v-model="form.event_date" type="date" required></label>
|
||||
<div v-else class="countdown-title-fields">
|
||||
<label>年份<input v-model.number="form.lunar_year" type="number" min="1900" max="2099" required></label>
|
||||
<label>月份<select v-model.number="form.lunar_month"><option v-for="month in 12" :key="month" :value="month">{{month}}月</option></select></label>
|
||||
<label>日期<select v-model.number="form.lunar_day"><option v-for="day in 30" :key="day" :value="day">{{day}}日</option></select></label>
|
||||
</div>
|
||||
<label v-if="form.calendar_mode==='lunar'"><input v-model="form.leap_month" type="checkbox"> 闰月(负数月份编码)</label>
|
||||
<label><input v-model="form.ignore_year" type="checkbox"> 忽略年份<span v-if="form.calendar_mode==='lunar'">,每年按农历计算</span></label>
|
||||
<label>类型<select v-model="form.kind"><option value="countdown">倒数日</option><option value="anniversary">纪念日</option><option value="birthday">生日</option></select></label>
|
||||
<label>重复<select v-model="form.repeat_rule"><option value="none">不重复</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option></select></label>
|
||||
<footer><button type="button" class="secondary" @click="open=false">取消</button><button class="primary-small">保存</button></footer>
|
||||
</form></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { countdownDayText, countdownKindLabel, dateKey, defaultView, habitWeek, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, shouldToggleRowSwipe } from './mvp-utils'
|
||||
import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey, defaultView, habitWeek, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, shouldToggleRowSwipe } from './mvp-utils'
|
||||
|
||||
describe('MVP view utilities', () => {
|
||||
it('formats a local date as YYYY-MM-DD', () => {
|
||||
@@ -70,6 +70,8 @@ describe('MVP view utilities', () => {
|
||||
expect(countdownKindLabel('countdown')).toBe('倒数日')
|
||||
expect(countdownKindLabel('anniversary')).toBe('纪念日')
|
||||
expect(countdownKindLabel('birthday')).toBe('生日')
|
||||
expect(calendarModeLabel('solar')).toBe('公历')
|
||||
expect(calendarModeLabel('lunar')).toBe('农历')
|
||||
})
|
||||
|
||||
it('toggles a row for a deliberate mostly-horizontal swipe', () => {
|
||||
|
||||
@@ -86,6 +86,10 @@ export function countdownKindLabel(kind: string) {
|
||||
return ({ countdown: '倒数日', anniversary: '纪念日', birthday: '生日' } as Record<string, string>)[kind] ?? '倒数日'
|
||||
}
|
||||
|
||||
export function calendarModeLabel(mode: string) {
|
||||
return mode === 'lunar' ? '农历' : '公历'
|
||||
}
|
||||
|
||||
export function shouldToggleRowSwipe(deltaX: number, deltaY: number) {
|
||||
return deltaX >= 64 && deltaX > Math.abs(deltaY) * 1.5
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""add lunar calendar fields to countdowns
|
||||
|
||||
Revision ID: 0011_lunar_countdowns
|
||||
Revises: 0010
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0011_lunar_countdowns"
|
||||
down_revision = "0010"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("countdowns") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("calendar_mode", sa.String(8), nullable=False, server_default="solar")
|
||||
)
|
||||
batch_op.add_column(sa.Column("lunar_month", sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column("lunar_day", sa.Integer(), nullable=True))
|
||||
batch_op.add_column(
|
||||
sa.Column("ignore_year", sa.Boolean(), nullable=False, server_default=sa.false())
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("countdowns") as batch_op:
|
||||
batch_op.drop_column("ignore_year")
|
||||
batch_op.drop_column("lunar_day")
|
||||
batch_op.drop_column("lunar_month")
|
||||
batch_op.drop_column("calendar_mode")
|
||||
@@ -14,6 +14,7 @@ dependencies = [
|
||||
"python-multipart>=0.0.20,<1",
|
||||
"uuid-utils>=0.11,<1",
|
||||
"structlog>=25,<26",
|
||||
"lunar-python>=1.2,<2",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
from datetime import date
|
||||
|
||||
from backend.lunar_support import (
|
||||
lunar_label_with_year,
|
||||
lunar_to_solar_safe,
|
||||
next_lunar_occurrence,
|
||||
solar_to_lunar_text,
|
||||
)
|
||||
from backend.mvp import countdown_occurrence, countdown_status
|
||||
|
||||
|
||||
@@ -23,6 +29,17 @@ def create_countdown(client, **overrides):
|
||||
return client.post("/api/v1/countdowns", json=payload)
|
||||
|
||||
|
||||
def test_lunar_date_math_labels_leap_month_and_finds_next_occurrence():
|
||||
assert lunar_to_solar_safe(2025, -6, 1) == date(2025, 7, 25)
|
||||
assert lunar_to_solar_safe(2026, -6, 1) is None
|
||||
assert solar_to_lunar_text(date(2026, 9, 14)) == "农历八月初四"
|
||||
assert solar_to_lunar_text(date(2025, 7, 25)) == "农历闰六月初一"
|
||||
assert lunar_label_with_year(date(2025, 7, 25)) == "农历二〇二五年闰六月初一"
|
||||
assert next_lunar_occurrence(8, 4, True, "none", date(2026, 9, 1)) == date(2026, 9, 14)
|
||||
assert next_lunar_occurrence(8, 4, True, "none", date(2026, 9, 15)) == date(2027, 9, 4)
|
||||
assert next_lunar_occurrence(-6, 1, True, "yearly", date(2026, 1, 1)) == date(2036, 7, 23)
|
||||
|
||||
|
||||
def test_countdown_date_math_uses_date_only_recurrence_semantics():
|
||||
assert countdown_status(date(2026, 9, 10), date(2026, 9, 6)) == (4, "还有 4 天")
|
||||
assert countdown_status(date(2026, 9, 6), date(2026, 9, 6)) == (0, "就是今天")
|
||||
@@ -32,6 +49,41 @@ def test_countdown_date_math_uses_date_only_recurrence_semantics():
|
||||
assert countdown_occurrence(date(2026, 9, 1), "weekly", date(2026, 9, 8)) == date(2026, 9, 8)
|
||||
|
||||
|
||||
def test_lunar_countdown_crud_validation_and_display(client):
|
||||
boot(client)
|
||||
created = create_countdown(
|
||||
client,
|
||||
title="农历生日",
|
||||
event_date="2026-01-01",
|
||||
kind="birthday",
|
||||
calendar_mode="lunar",
|
||||
lunar_month=8,
|
||||
lunar_day=4,
|
||||
ignore_year=True,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
item = created.json()
|
||||
assert item["event_date"] == "2026-09-14"
|
||||
assert item["calendar_mode"] == "lunar"
|
||||
assert item["lunar_month"] == 8
|
||||
assert item["lunar_day"] == 4
|
||||
assert item["ignore_year"] is True
|
||||
assert item["lunar_text"] == "农历八月初四"
|
||||
|
||||
updated = client.patch(
|
||||
f"/api/v1/countdowns/{item['id']}",
|
||||
json={"lunar_month": -6, "lunar_day": 1, "event_date": "2025-01-01"},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["event_date"] == "2025-07-25"
|
||||
assert updated.json()["lunar_text"] == "农历闰六月初一"
|
||||
|
||||
assert create_countdown(client, calendar_mode="lunar", lunar_month=8).status_code == 422
|
||||
assert create_countdown(client, calendar_mode="lunar", lunar_month=0, lunar_day=1).status_code == 422
|
||||
assert create_countdown(client, calendar_mode="lunar", lunar_month=-6, lunar_day=1, event_date="2026-01-01").status_code == 422
|
||||
assert create_countdown(client, calendar_mode="solar", lunar_month=8, lunar_day=4).status_code == 422
|
||||
|
||||
|
||||
def test_countdown_crud_single_pin_archive_restore_and_purge(client):
|
||||
boot(client)
|
||||
first = create_countdown(client).json()
|
||||
@@ -96,19 +148,38 @@ def test_countdown_validation_and_user_isolation(client):
|
||||
|
||||
def test_countdowns_backup_replace_and_merge_round_trip(client):
|
||||
boot(client)
|
||||
active = create_countdown(client, title="周年", kind="anniversary", repeat_rule="yearly", pinned=True).json()
|
||||
active = create_countdown(
|
||||
client,
|
||||
title="周年",
|
||||
event_date="2025-01-01",
|
||||
kind="anniversary",
|
||||
repeat_rule="yearly",
|
||||
pinned=True,
|
||||
calendar_mode="lunar",
|
||||
lunar_month=-6,
|
||||
lunar_day=1,
|
||||
ignore_year=True,
|
||||
).json()
|
||||
archived = create_countdown(client, title="旧日", event_date="2020-01-02").json()
|
||||
client.delete(f"/api/v1/countdowns/{archived['id']}")
|
||||
|
||||
exported = client.get("/api/v1/export").json()
|
||||
assert {item["title"] for item in exported["countdowns"]} == {"周年", "旧日"}
|
||||
assert next(item for item in exported["countdowns"] if item["title"] == "周年")["pinned"] is True
|
||||
exported_lunar = next(item for item in exported["countdowns"] if item["title"] == "周年")
|
||||
assert exported_lunar["pinned"] is True
|
||||
assert exported_lunar["calendar_mode"] == "lunar"
|
||||
assert exported_lunar["lunar_month"] == -6
|
||||
assert exported_lunar["lunar_day"] == 1
|
||||
assert exported_lunar["ignore_year"] is True
|
||||
assert next(item for item in exported["countdowns"] if item["title"] == "旧日")["archived_at"]
|
||||
|
||||
create_countdown(client, title="干扰数据")
|
||||
restored = client.post("/api/v1/restore", params={"mode": "replace"}, json=exported)
|
||||
assert restored.status_code == 200
|
||||
assert {item["title"] for item in client.get("/api/v1/countdowns").json()} == {"周年"}
|
||||
restored_active = next(item for item in client.get("/api/v1/countdowns").json() if item["title"] == "周年")
|
||||
assert restored_active["calendar_mode"] == "lunar"
|
||||
assert restored_active["lunar_month"] == -6
|
||||
assert restored_active["ignore_year"] is True
|
||||
assert {item["title"] for item in client.get("/api/v1/countdowns", params={"archived": True}).json()} == {"旧日"}
|
||||
|
||||
merged = client.post("/api/v1/restore", params={"mode": "merge"}, json=exported)
|
||||
|
||||
@@ -276,6 +276,7 @@ dependencies = [
|
||||
{ name = "argon2-cffi" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "lunar-python" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||
@@ -299,6 +300,7 @@ requires-dist = [
|
||||
{ name = "argon2-cffi", specifier = ">=25,<26" },
|
||||
{ name = "asyncpg", specifier = ">=0.30,<1" },
|
||||
{ name = "fastapi", specifier = ">=0.116,<1" },
|
||||
{ name = "lunar-python", specifier = ">=1.2,<2" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.10,<3" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.20,<1" },
|
||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0,<3" },
|
||||
@@ -490,6 +492,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lunar-python"
|
||||
version = "1.4.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/45/5154c95ae7feaab7ca508e71c3288692c09952dfe33b03b7c2f18a32e2cd/lunar_python-1.4.8.tar.gz", hash = "sha256:3aa11cc73c25e70ddf0ba5bdac7398c03acc9491a3aa512a91c9642973b669d6", size = 105862 }
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
version = "1.4.1"
|
||||
|
||||
Reference in New Issue
Block a user