feat: 计划按自然月管理,加「应用到每天」和下月未排提醒

## 为什么不是「从计划开始日起 30 天」
上一版是那样做的,截图一看就不对:计划是月中建的,日历显示
「7月26日 — 8月25日」,既不像本月也不像下月,用户想问「这个月还剩什么」
根本对不上。改成自然月,日历就是一眼能认的月历。

能翻到下月 —— 否则「做下月计划」这件事本身做不了。

## 校验从「Day 上限」改成「日期」
PlanTask.Day 是相对计划开始日的,月份边界是相对今天的,两者会错开。
原来卡 Day<=30,计划建了几天之后下月末就换算成 Day=40 多,
用户在日历上明明看得到那格、点进去却存不了。

现在的规则:不早于计划开始日(下界放到开始日而不是本月 1 号——模板生成的
历史节点可能在上个月,那些要允许原地改),不晚于下月月底。

## 应用到每天
一天建一条,不做「一条 + repeat 字段读时展开」:勾选状态是按天的,
展开方案还得再开一张表记「哪天打过勾」,而直接建 N 条天然就有这个能力。

范围止于**这一天所在那个月**的月底,不跨月——「应用到每天」说的是这个月每天,
跨过去就变成用户没要求的事了。只有新增能选,已有的那条改成每天等于凭空
复制 N 份(用户想要的是「以后每天」,不是「把这条撒开」)。

## 首页那个提醒
GET /pets/:id/plan/month-status 返回本月件数/剩余、下月件数、need_next。

need_next 只在**月末最后 7 天**且下月为空时为真:月初就催「排下个月」太早,
用户会当成噪音。角标做在图标上而不是弹 toast——每次进首页弹一下很烦,
而这件事不紧急、看见就行。

副标题有优先级:「8 月计划还没排」压过「本月都做完了」,因为前者有时效。
四种状态实跑验过,包括让位关系。

## 顺手
pet-switch 加 show-add 属性。计划页只需要在既有宠物之间切,建档入口在首页
和「我的」就够了,到处摆一个「添加」会让这条 chip 变得很长。

本月/下月的切换用新的 .link-tabs 而不是挂在 .head-links 上:首页的
「管理 / 全部完成」也用 head-links,那是两个并列动作,不该有一个是灰的。

## 验证(预生产库实跑)
  月边界     本月末通过、下月末通过、下下月拒(报出具体日期)
  应用到每天  今天→本月末 2 条,没跨月
  month-status  下月清空 → need_next 翻 true;补一条 → 翻回 false
  四种副标题  下月未排 / 本月还有 N 件 / 本月都做完了 / 还没有安排,含优先级
  测试数据全部还原

上一条 cd 打错目录,导致「日期窗口」那次改动没写进文件、只有批量那半截进去了,
所以中间出现过一次 planWindow undefined。这次一并改对。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-30 11:09:06 +08:00
parent bbc9706576
commit 9b30bfaaff
13 changed files with 278 additions and 76 deletions
+16 -3
View File
@@ -188,11 +188,12 @@ type planTaskReq struct {
Title string `json:"title"`
Description string `json:"description"`
SheetType string `json:"sheet_type"`
Daily bool `json:"daily"`
}
func (r planTaskReq) input() service.PlanTaskInput {
return service.PlanTaskInput{Date: r.Date, Title: r.Title,
Description: r.Description, SheetType: r.SheetType}
Description: r.Description, SheetType: r.SheetType, Daily: r.Daily}
}
// AddPlanTask POST /api/pets/:id/plan-tasks 往 30 天计划里加一条
@@ -202,12 +203,13 @@ func (h *Handler) AddPlanTask(c *gin.Context) {
response.FailParams(c, err.Error())
return
}
t, err := h.svc.AddPlanTask(middleware.UserID(c), idParam(c, "id"), req.input())
list, err := h.svc.AddPlanTask(middleware.UserID(c), idParam(c, "id"), req.input())
if err != nil {
respondErr(c, err)
return
}
response.OK(c, t)
// daily 会一次建多条,统一返回数组,前端不用分两种情况处理
response.OK(c, list)
}
// UpdatePlanTask PUT /api/plan-tasks/:id
@@ -232,3 +234,14 @@ func (h *Handler) DeletePlanTask(c *gin.Context) {
}
response.OK(c, nil)
}
// PlanMonthStatus GET /api/pets/:id/plan/month-status
// 首页「管理计划」那个提醒角标要的:本月剩几件、下月排了没有
func (h *Handler) PlanMonthStatus(c *gin.Context) {
st, err := h.svc.PlanMonthStatus(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, st)
}
+1
View File
@@ -75,6 +75,7 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
g.GET("/pets/:id/plan", h.GetPlan)
g.GET("/pets/:id/plan/calendar", h.PlanCalendar)
g.GET("/pets/:id/plan/month-status", h.PlanMonthStatus)
g.GET("/pets/:id/day-plan", h.DayPlan)
g.POST("/pets/:id/ai-plan", h.CreateAIPlan)
g.POST("/ai-plan/:id/apply", h.ApplyAIPlan)
+118 -21
View File
@@ -283,6 +283,10 @@ type PlanTaskInput struct {
Title string
Description string
SheetType string // 关联的记录类型 code,可空
// Daily 应用到每天:从 Date 起到窗口末尾,一天建一条。
// 不做「一条 + repeat 字段读时展开」是因为勾选状态是按天的——
// 那样得再开一张表记「哪天打过勾」,而直接建 N 条天然就有这个能力。
Daily bool
}
// planOf 取这只宠物当前的 30 天计划,顺带做归属校验
@@ -300,13 +304,26 @@ func (s *Service) planOf(userID, petID string) (*model.Plan, error) {
return &plan, nil
}
// planSpanDays 计划覆盖到第几天。内置模板会生成 day=30 的节点(第 31 天),
// 所以窗口是 [0, 30] 而不是 [0, 29]——按 29 卡的话模板自己生成的最后一个节点
// 就落在窗口外:日历上没有那一格,用户看得到却改不动也删不掉。
const planSpanDays = 30
// 计划以「自然月」为单位管理。
//
// 不用「从计划开始日起 30 天」那种滚动窗口:计划可能是月中建的,那样窗口会
// 跨两个月,用户看着一个 7月26日—8月25日 的日历,既不像本月也不像下月,
// 「这个月还有什么没做」根本对不上。按自然月切,日历就是一眼能认的月历。
//
// 允许安排到下月末,否则「做下月计划」这件事本身做不了。
// dayOffset 把日期换算成计划里的 Day。只接受计划开始日起 planSpanDays 天内——
// 「只做 30 天内的计划」是产品定的边界,超出的话日历上根本没有那一格
// monthBounds 某个月的首尾。off=0 本月,off=1 下月
func monthBounds(off int) (time.Time, time.Time) {
n := time.Now()
first := time.Date(n.Year(), n.Month(), 1, 0, 0, 0, 0, time.Local).AddDate(0, off, 0)
return first, first.AddDate(0, 1, -1)
}
// dayOffset 把日期换算成 PlanTask.Day(相对计划开始日)。
//
// 校验按**日期**而不是 Day 上限:Day 是相对计划开始日的,月份边界是相对今天的,
// 两者会错开。按 Day<=30 卡的话,计划建了几天之后下月末就换算成 Day=40 多,
// 用户在日历上明明看得到那格、点进去却存不了。
func dayOffset(plan *model.Plan, dateStr string) (int, error) {
if plan.StartDate == nil {
return 0, errors.New("计划还没有开始日期")
@@ -315,18 +332,24 @@ func dayOffset(plan *model.Plan, dateStr string) (int, error) {
if err != nil {
return 0, ErrInvalidParam
}
start := time.Date(plan.StartDate.Year(), plan.StartDate.Month(), plan.StartDate.Day(),
st := time.Date(plan.StartDate.Year(), plan.StartDate.Month(), plan.StartDate.Day(),
0, 0, 0, 0, time.Local)
day := int(d.Sub(start).Hours() / 24)
if day < 0 || day > planSpanDays {
return 0, errors.New("只能安排计划开始后 30 天内的事,这天超出范围了")
// 下界放到计划开始日而不是本月 1 号:模板生成的历史节点可能在上个月,
// 那些要允许原地改(改标题时日期会原样传回来)
if d.Before(st) {
return 0, errors.New("这天在计划开始之前,排不了")
}
return day, nil
_, nextEnd := monthBounds(1)
if d.After(nextEnd) {
return 0, errors.New("最远只能排到下个月月底(" + nextEnd.Format("1月2日") + "")
}
return int(d.Sub(st).Hours() / 24), nil
}
// AddPlanTask 用户自己往计划里加一条
func (s *Service) AddPlanTask(userID, petID string, in PlanTaskInput) (*model.PlanTask, error) {
if strings.TrimSpace(in.Title) == "" {
// AddPlanTask 用户自己往计划里加。Daily=true 时从这天起到窗口末尾每天一条
func (s *Service) AddPlanTask(userID, petID string, in PlanTaskInput) ([]model.PlanTask, error) {
title := strings.TrimSpace(in.Title)
if title == "" {
return nil, ErrInvalidParam
}
plan, err := s.planOf(userID, petID)
@@ -337,16 +360,43 @@ func (s *Service) AddPlanTask(userID, petID string, in PlanTaskInput) (*model.Pl
if err != nil {
return nil, err
}
t := model.PlanTask{
PlanID: plan.ID, Day: day, DayLabel: dayLabelFor(day),
Title: strings.TrimSpace(in.Title), Description: strings.TrimSpace(in.Description),
SheetType: in.SheetType,
// 要建几天。非 daily 就一天;daily 从这天排到窗口末尾
// 要建几天。daily 从这天排到**这一天所在那个月**的月底:
// 「应用到每天」说的是这个月每天,跨到下月去就变成用户没要求的事了
count := 1
if in.Daily {
from, e := time.ParseInLocation("2006-01-02", in.Date, time.Local)
if e != nil {
return nil, ErrInvalidParam
}
monthEnd := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, time.Local).
AddDate(0, 1, -1)
count = int(monthEnd.Sub(from).Hours()/24) + 1
if count < 1 {
count = 1
}
if count > 31 {
count = 31 // 兜一层,别让脏日期算出个巨大的数
}
}
if err := s.db.Create(&t).Error; err != nil {
out := make([]model.PlanTask, 0, count)
for i := 0; i < count; i++ {
d := day + i
out = append(out, model.PlanTask{
PlanID: plan.ID, Day: d, DayLabel: dayLabelFor(d),
Title: title, Description: strings.TrimSpace(in.Description),
SheetType: in.SheetType,
})
}
if err := s.db.Create(&out).Error; err != nil {
return nil, err
}
t.Date = plan.StartDate.AddDate(0, 0, day).Format("2006-01-02")
return &t, nil
for i := range out {
out[i].Date = plan.StartDate.AddDate(0, 0, out[i].Day).Format("2006-01-02")
}
return out, nil
}
// UpdatePlanTask 改标题/描述/日期
@@ -408,3 +458,50 @@ func dayLabelFor(day int) string {
return "第 " + strconv.Itoa(day+1) + " 天"
}
}
// PlanMonthStatus 首页那个提醒角标要的东西
type PlanMonthStatus struct {
Month string `json:"month"` // "2026-07"
ThisMonth int `json:"this_month"` // 本月有几件
ThisUndone int `json:"this_undone"` // 本月还剩几件
NextMonth int `json:"next_month"` // 下月有几件
// NeedNext 该提醒用户排下月计划了。
// 只在月末最后 7 天提醒:月初就催「排下个月」太早,用户会当成噪音关掉。
NeedNext bool `json:"need_next"`
NextLabel string `json:"next_label"` // "8 月"
}
// PlanMonthStatus 统计本月/下月的计划件数
func (s *Service) PlanMonthStatus(userID, petID string) (*PlanMonthStatus, error) {
plan, err := s.planOf(userID, petID)
if err != nil {
return nil, err
}
thisFirst, thisEnd := monthBounds(0)
nextFirst, nextEnd := monthBounds(1)
var tasks []model.PlanTask
s.db.Where("plan_id = ?", plan.ID).Find(&tasks)
out := &PlanMonthStatus{
Month: thisFirst.Format("2006-01"),
NextLabel: nextFirst.Format("1 月"),
}
for _, t := range tasks {
d := plan.StartDate.AddDate(0, 0, t.Day)
switch {
case !d.Before(thisFirst) && !d.After(thisEnd):
out.ThisMonth++
if !t.Done {
out.ThisUndone++
}
case !d.Before(nextFirst) && !d.After(nextEnd):
out.NextMonth++
}
}
// 距月底还剩几天。用 24h 取整会在夏令时地区差一天,这里按日期差算
left := int(thisEnd.Sub(time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(),
0, 0, 0, 0, time.Local)).Hours()/24)
out.NeedNext = out.NextMonth == 0 && left <= 7
return out, nil
}