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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user