Files
walkies/internal/schedule/schedule.go
James Griffin 0af53c9b55
All checks were successful
CI / Go tests & lint (push) Successful in 9s
CI / Frontend tests & type-check (push) Successful in 44s
CI / Go tests & lint (pull_request) Successful in 9s
CI / Frontend tests & type-check (pull_request) Successful in 44s
Fix null volunteers array in shift instance JSON responses
Initialize slice helpers with make() instead of var declaration so
empty results serialize as [] instead of null in JSON, fixing the
frontend TypeError on the schedules page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 19:08:37 -03:00

721 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package schedule
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
var (
ErrNotFound = fmt.Errorf("not found")
ErrAlreadyExists = fmt.Errorf("instances already generated for this period")
)
// ---------------------------------------------------------------------------
// Models
// ---------------------------------------------------------------------------
type ShiftTemplate struct {
ID int64 `json:"id"`
Name string `json:"name"`
DayOfWeek int `json:"day_of_week"` // matches time.Weekday: 0=Sun, 6=Sat
StartTime string `json:"start_time"` // "HH:MM:SS"
EndTime string `json:"end_time"`
MinCapacity int `json:"min_capacity"`
MaxCapacity int `json:"max_capacity"`
Roles []TemplateRole `json:"roles"`
VolunteerIDs []int64 `json:"volunteer_ids"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type TemplateRole struct {
ID int64 `json:"id"`
TemplateID int64 `json:"template_id"`
RoleName string `json:"role_name"`
Count int `json:"count"`
}
type ShiftInstance struct {
ID int64 `json:"id"`
TemplateID *int64 `json:"template_id,omitempty"`
Name string `json:"name"`
Date string `json:"date"` // "YYYY-MM-DD"
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
MinCapacity int `json:"min_capacity"`
MaxCapacity int `json:"max_capacity"`
Status string `json:"status"` // "draft" or "published"
Year int `json:"year"`
Month int `json:"month"`
Volunteers []InstanceVolunteer `json:"volunteers"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type InstanceVolunteer struct {
InstanceID int64 `json:"instance_id"`
VolunteerID int64 `json:"volunteer_id"`
Name string `json:"name"`
Confirmed bool `json:"confirmed"`
ConfirmedAt *time.Time `json:"confirmed_at,omitempty"`
}
// ---------------------------------------------------------------------------
// Input types
// ---------------------------------------------------------------------------
type CreateTemplateInput struct {
Name string `json:"name"`
DayOfWeek int `json:"day_of_week"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
MinCapacity int `json:"min_capacity"`
MaxCapacity int `json:"max_capacity"`
Roles []TemplateRole `json:"roles"`
VolunteerIDs []int64 `json:"volunteer_ids"`
}
type UpdateTemplateInput struct {
Name *string `json:"name"`
DayOfWeek *int `json:"day_of_week"`
StartTime *string `json:"start_time"`
EndTime *string `json:"end_time"`
MinCapacity *int `json:"min_capacity"`
MaxCapacity *int `json:"max_capacity"`
Roles []TemplateRole `json:"roles"`
VolunteerIDs []int64 `json:"volunteer_ids"`
}
type UpdateInstanceInput struct {
VolunteerIDs *[]int64 `json:"volunteer_ids"`
MinCapacity *int `json:"min_capacity"`
MaxCapacity *int `json:"max_capacity"`
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// ---------------------------------------------------------------------------
// Template operations
// ---------------------------------------------------------------------------
func (s *Store) CreateTemplate(ctx context.Context, in CreateTemplateInput) (*ShiftTemplate, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx,
`INSERT INTO shift_templates (name, day_of_week, start_time, end_time, min_capacity, max_capacity)
VALUES (?, ?, ?, ?, ?, ?)`,
in.Name, in.DayOfWeek, in.StartTime, in.EndTime, in.MinCapacity, in.MaxCapacity,
)
if err != nil {
return nil, fmt.Errorf("insert template: %w", err)
}
id, _ := res.LastInsertId()
if err := upsertTemplateRoles(ctx, tx, id, in.Roles); err != nil {
return nil, err
}
if err := upsertTemplateVolunteers(ctx, tx, id, in.VolunteerIDs); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return s.GetTemplate(ctx, id)
}
func (s *Store) GetTemplate(ctx context.Context, id int64) (*ShiftTemplate, error) {
t := &ShiftTemplate{}
var createdAt, updatedAt string
err := s.db.QueryRowContext(ctx,
`SELECT id, name, day_of_week, start_time, end_time, min_capacity, max_capacity, created_at, updated_at
FROM shift_templates WHERE id = ?`, id,
).Scan(&t.ID, &t.Name, &t.DayOfWeek, &t.StartTime, &t.EndTime,
&t.MinCapacity, &t.MaxCapacity, &createdAt, &updatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get template: %w", err)
}
t.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
t.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
roles, err := s.templateRoles(ctx, id)
if err != nil {
return nil, err
}
t.Roles = roles
vids, err := s.templateVolunteerIDs(ctx, id)
if err != nil {
return nil, err
}
t.VolunteerIDs = vids
return t, nil
}
func (s *Store) ListTemplates(ctx context.Context) ([]ShiftTemplate, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, day_of_week, start_time, end_time, min_capacity, max_capacity, created_at, updated_at
FROM shift_templates ORDER BY day_of_week, start_time`)
if err != nil {
return nil, fmt.Errorf("list templates: %w", err)
}
defer rows.Close()
var templates []ShiftTemplate
for rows.Next() {
var t ShiftTemplate
var createdAt, updatedAt string
if err := rows.Scan(&t.ID, &t.Name, &t.DayOfWeek, &t.StartTime, &t.EndTime,
&t.MinCapacity, &t.MaxCapacity, &createdAt, &updatedAt); err != nil {
return nil, err
}
t.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
t.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
templates = append(templates, t)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Load roles and volunteers for each template
for i := range templates {
roles, err := s.templateRoles(ctx, templates[i].ID)
if err != nil {
return nil, err
}
templates[i].Roles = roles
vids, err := s.templateVolunteerIDs(ctx, templates[i].ID)
if err != nil {
return nil, err
}
templates[i].VolunteerIDs = vids
}
return templates, nil
}
func (s *Store) UpdateTemplate(ctx context.Context, id int64, in UpdateTemplateInput) (*ShiftTemplate, error) {
t, err := s.GetTemplate(ctx, id)
if err != nil {
return nil, err
}
name := t.Name
dow := t.DayOfWeek
startTime := t.StartTime
endTime := t.EndTime
minCap := t.MinCapacity
maxCap := t.MaxCapacity
if in.Name != nil {
name = *in.Name
}
if in.DayOfWeek != nil {
dow = *in.DayOfWeek
}
if in.StartTime != nil {
startTime = *in.StartTime
}
if in.EndTime != nil {
endTime = *in.EndTime
}
if in.MinCapacity != nil {
minCap = *in.MinCapacity
}
if in.MaxCapacity != nil {
maxCap = *in.MaxCapacity
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx,
`UPDATE shift_templates SET name=?, day_of_week=?, start_time=?, end_time=?,
min_capacity=?, max_capacity=?, updated_at=NOW() WHERE id=?`,
name, dow, startTime, endTime, minCap, maxCap, id,
)
if err != nil {
return nil, fmt.Errorf("update template: %w", err)
}
if in.Roles != nil {
if _, err := tx.ExecContext(ctx, `DELETE FROM shift_template_roles WHERE template_id = ?`, id); err != nil {
return nil, fmt.Errorf("clear roles: %w", err)
}
if err := upsertTemplateRoles(ctx, tx, id, in.Roles); err != nil {
return nil, err
}
}
if in.VolunteerIDs != nil {
if _, err := tx.ExecContext(ctx, `DELETE FROM shift_template_volunteers WHERE template_id = ?`, id); err != nil {
return nil, fmt.Errorf("clear volunteers: %w", err)
}
if err := upsertTemplateVolunteers(ctx, tx, id, in.VolunteerIDs); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return s.GetTemplate(ctx, id)
}
func (s *Store) DeleteTemplate(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM shift_templates WHERE id = ?`, id)
return err
}
// ---------------------------------------------------------------------------
// Instance operations
// ---------------------------------------------------------------------------
// GenerateInstances creates draft shift instances for every template × date in
// the given month. Returns ErrAlreadyExists if instances already exist for
// that month (FR-S02).
func (s *Store) GenerateInstances(ctx context.Context, year, month int) ([]ShiftInstance, error) {
var count int
if err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM shift_instances WHERE year = ? AND month = ?`, year, month,
).Scan(&count); err != nil {
return nil, fmt.Errorf("check existing: %w", err)
}
if count > 0 {
return nil, ErrAlreadyExists
}
templates, err := s.ListTemplates(ctx)
if err != nil {
return nil, err
}
// Find all dates in the month for each template's day of week
first := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
daysInMonth := daysIn(year, month)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
var instanceIDs []int64
for _, tmpl := range templates {
for d := 0; d < daysInMonth; d++ {
day := first.AddDate(0, 0, d)
if int(day.Weekday()) != tmpl.DayOfWeek {
continue
}
res, err := tx.ExecContext(ctx,
`INSERT INTO shift_instances
(template_id, name, date, start_time, end_time, min_capacity, max_capacity, status, year, month)
VALUES (?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?)`,
tmpl.ID, tmpl.Name, day.Format("2006-01-02"),
tmpl.StartTime, tmpl.EndTime,
tmpl.MinCapacity, tmpl.MaxCapacity,
year, month,
)
if err != nil {
return nil, fmt.Errorf("insert instance: %w", err)
}
instID, _ := res.LastInsertId()
instanceIDs = append(instanceIDs, instID)
// Copy recurring volunteer assignments from template
for _, vid := range tmpl.VolunteerIDs {
if _, err := tx.ExecContext(ctx,
`INSERT IGNORE INTO shift_instance_volunteers (instance_id, volunteer_id) VALUES (?, ?)`,
instID, vid,
); err != nil {
return nil, fmt.Errorf("copy volunteer: %w", err)
}
}
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return s.ListInstances(ctx, year, month, 0)
}
// ListInstances returns instances for a month. When volunteerID > 0, only
// returns published instances where that volunteer is assigned.
func (s *Store) ListInstances(ctx context.Context, year, month int, volunteerID int64) ([]ShiftInstance, error) {
query := `SELECT id, template_id, name, date, start_time, end_time,
min_capacity, max_capacity, status, year, month, created_at, updated_at
FROM shift_instances WHERE year = ? AND month = ?`
args := []any{year, month}
if volunteerID > 0 {
query += ` AND status = 'published'
AND id IN (SELECT instance_id FROM shift_instance_volunteers WHERE volunteer_id = ?)`
args = append(args, volunteerID)
}
query += ` ORDER BY date, start_time`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list instances: %w", err)
}
defer rows.Close()
var instances []ShiftInstance
for rows.Next() {
inst, err := scanInstance(rows)
if err != nil {
return nil, err
}
instances = append(instances, *inst)
}
if err := rows.Err(); err != nil {
return nil, err
}
for i := range instances {
vols, err := s.instanceVolunteers(ctx, instances[i].ID)
if err != nil {
return nil, err
}
instances[i].Volunteers = vols
}
return instances, nil
}
func (s *Store) GetInstance(ctx context.Context, id int64) (*ShiftInstance, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, template_id, name, date, start_time, end_time,
min_capacity, max_capacity, status, year, month, created_at, updated_at
FROM shift_instances WHERE id = ?`, id)
inst, err := scanInstanceRow(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get instance: %w", err)
}
vols, err := s.instanceVolunteers(ctx, inst.ID)
if err != nil {
return nil, err
}
inst.Volunteers = vols
return inst, nil
}
// UpdateInstance edits volunteer assignments and/or capacity on any instance.
// For published instances, volunteer confirmation statuses are reset (FR-S09).
// Returns the previous and new volunteer ID sets so the caller can send notifications.
func (s *Store) UpdateInstance(ctx context.Context, id int64, in UpdateInstanceInput) (inst *ShiftInstance, added []int64, err error) {
inst, err = s.GetInstance(ctx, id)
if err != nil {
return nil, nil, err
}
tx, txErr := s.db.BeginTx(ctx, nil)
if txErr != nil {
return nil, nil, fmt.Errorf("begin tx: %w", txErr)
}
defer tx.Rollback()
if in.MinCapacity != nil || in.MaxCapacity != nil {
minCap := inst.MinCapacity
maxCap := inst.MaxCapacity
if in.MinCapacity != nil {
minCap = *in.MinCapacity
}
if in.MaxCapacity != nil {
maxCap = *in.MaxCapacity
}
if _, err := tx.ExecContext(ctx,
`UPDATE shift_instances SET min_capacity=?, max_capacity=?, updated_at=NOW() WHERE id=?`,
minCap, maxCap, id,
); err != nil {
return nil, nil, fmt.Errorf("update capacity: %w", err)
}
}
if in.VolunteerIDs != nil {
// Determine newly added volunteers (FR-S10)
existing := make(map[int64]bool)
for _, v := range inst.Volunteers {
existing[v.VolunteerID] = true
}
for _, vid := range *in.VolunteerIDs {
if !existing[vid] {
added = append(added, vid)
}
}
// Replace assignments
if _, err := tx.ExecContext(ctx,
`DELETE FROM shift_instance_volunteers WHERE instance_id = ?`, id,
); err != nil {
return nil, nil, fmt.Errorf("clear volunteers: %w", err)
}
for _, vid := range *in.VolunteerIDs {
if _, err := tx.ExecContext(ctx,
`INSERT INTO shift_instance_volunteers (instance_id, volunteer_id) VALUES (?, ?)`,
id, vid,
); err != nil {
return nil, nil, fmt.Errorf("insert volunteer: %w", err)
}
}
// Reset confirmation for published shifts (FR-S09)
if inst.Status == "published" {
if _, err := tx.ExecContext(ctx,
`UPDATE shift_instance_volunteers SET confirmed=0, confirmed_at=NULL WHERE instance_id=?`, id,
); err != nil {
return nil, nil, fmt.Errorf("reset confirmations: %w", err)
}
}
}
if err := tx.Commit(); err != nil {
return nil, nil, fmt.Errorf("commit: %w", err)
}
inst, err = s.GetInstance(ctx, id)
return inst, added, err
}
// PublishMonth marks all draft instances for the month as published and returns
// a map of volunteerID → []ShiftInstance for notification purposes (FR-S04).
func (s *Store) PublishMonth(ctx context.Context, year, month int) (map[int64][]ShiftInstance, error) {
if _, err := s.db.ExecContext(ctx,
`UPDATE shift_instances SET status='published', updated_at=NOW()
WHERE year=? AND month=? AND status='draft'`, year, month,
); err != nil {
return nil, fmt.Errorf("publish: %w", err)
}
instances, err := s.ListInstances(ctx, year, month, 0)
if err != nil {
return nil, err
}
byVol := make(map[int64][]ShiftInstance)
for _, inst := range instances {
for _, v := range inst.Volunteers {
byVol[v.VolunteerID] = append(byVol[v.VolunteerID], inst)
}
}
return byVol, nil
}
// UnpublishMonth marks all published instances for the month back to draft and
// returns volunteer IDs who had assignments (for notifications FR-S05).
func (s *Store) UnpublishMonth(ctx context.Context, year, month int) ([]int64, error) {
// Collect affected volunteer IDs before unpublishing
rows, err := s.db.QueryContext(ctx,
`SELECT DISTINCT siv.volunteer_id
FROM shift_instance_volunteers siv
JOIN shift_instances si ON siv.instance_id = si.id
WHERE si.year=? AND si.month=? AND si.status='published'`, year, month,
)
if err != nil {
return nil, fmt.Errorf("query volunteers: %w", err)
}
defer rows.Close()
var volunteerIDs []int64
for rows.Next() {
var vid int64
if err := rows.Scan(&vid); err != nil {
return nil, err
}
volunteerIDs = append(volunteerIDs, vid)
}
if err := rows.Err(); err != nil {
return nil, err
}
if _, err := s.db.ExecContext(ctx,
`UPDATE shift_instances SET status='draft', updated_at=NOW()
WHERE year=? AND month=? AND status='published'`, year, month,
); err != nil {
return nil, fmt.Errorf("unpublish: %w", err)
}
return volunteerIDs, nil
}
// ConfirmShift marks a volunteer's attendance confirmation for a shift (FR-S06).
func (s *Store) ConfirmShift(ctx context.Context, instanceID, volunteerID int64) error {
result, err := s.db.ExecContext(ctx,
`UPDATE shift_instance_volunteers SET confirmed=1, confirmed_at=NOW()
WHERE instance_id=? AND volunteer_id=?`, instanceID, volunteerID,
)
if err != nil {
return fmt.Errorf("confirm shift: %w", err)
}
affected, _ := result.RowsAffected()
if affected == 0 {
return ErrNotFound
}
return nil
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func (s *Store) templateRoles(ctx context.Context, templateID int64) ([]TemplateRole, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, template_id, role_name, count FROM shift_template_roles WHERE template_id = ?`, templateID)
if err != nil {
return nil, fmt.Errorf("get template roles: %w", err)
}
defer rows.Close()
roles := make([]TemplateRole, 0)
for rows.Next() {
var r TemplateRole
if err := rows.Scan(&r.ID, &r.TemplateID, &r.RoleName, &r.Count); err != nil {
return nil, err
}
roles = append(roles, r)
}
return roles, rows.Err()
}
func (s *Store) templateVolunteerIDs(ctx context.Context, templateID int64) ([]int64, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT volunteer_id FROM shift_template_volunteers WHERE template_id = ?`, templateID)
if err != nil {
return nil, fmt.Errorf("get template volunteers: %w", err)
}
defer rows.Close()
ids := make([]int64, 0)
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (s *Store) instanceVolunteers(ctx context.Context, instanceID int64) ([]InstanceVolunteer, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT siv.instance_id, siv.volunteer_id, v.name, siv.confirmed, siv.confirmed_at
FROM shift_instance_volunteers siv
JOIN volunteers v ON v.id = siv.volunteer_id
WHERE siv.instance_id = ?
ORDER BY v.name`, instanceID,
)
if err != nil {
return nil, fmt.Errorf("get instance volunteers: %w", err)
}
defer rows.Close()
vols := make([]InstanceVolunteer, 0)
for rows.Next() {
var iv InstanceVolunteer
var confirmedAt sql.NullString
if err := rows.Scan(&iv.InstanceID, &iv.VolunteerID, &iv.Name, &iv.Confirmed, &confirmedAt); err != nil {
return nil, err
}
if confirmedAt.Valid {
t, _ := time.Parse("2006-01-02 15:04:05", confirmedAt.String)
iv.ConfirmedAt = &t
}
vols = append(vols, iv)
}
return vols, rows.Err()
}
func upsertTemplateRoles(ctx context.Context, tx *sql.Tx, templateID int64, roles []TemplateRole) error {
for _, r := range roles {
if _, err := tx.ExecContext(ctx,
`INSERT INTO shift_template_roles (template_id, role_name, count) VALUES (?, ?, ?)`,
templateID, r.RoleName, r.Count,
); err != nil {
return fmt.Errorf("insert role: %w", err)
}
}
return nil
}
func upsertTemplateVolunteers(ctx context.Context, tx *sql.Tx, templateID int64, volunteerIDs []int64) error {
for _, vid := range volunteerIDs {
if _, err := tx.ExecContext(ctx,
`INSERT IGNORE INTO shift_template_volunteers (template_id, volunteer_id) VALUES (?, ?)`,
templateID, vid,
); err != nil {
return fmt.Errorf("insert template volunteer: %w", err)
}
}
return nil
}
type instanceScanner interface {
Scan(dest ...any) error
}
func scanInstance(r instanceScanner) (*ShiftInstance, error) {
var inst ShiftInstance
var templateID sql.NullInt64
var createdAt, updatedAt string
if err := r.Scan(&inst.ID, &templateID, &inst.Name, &inst.Date, &inst.StartTime, &inst.EndTime,
&inst.MinCapacity, &inst.MaxCapacity, &inst.Status, &inst.Year, &inst.Month,
&createdAt, &updatedAt); err != nil {
return nil, err
}
if templateID.Valid {
inst.TemplateID = &templateID.Int64
}
inst.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
inst.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
if inst.Volunteers == nil {
inst.Volunteers = []InstanceVolunteer{}
}
return &inst, nil
}
func scanInstanceRow(row *sql.Row) (*ShiftInstance, error) {
var inst ShiftInstance
var templateID sql.NullInt64
var createdAt, updatedAt string
if err := row.Scan(&inst.ID, &templateID, &inst.Name, &inst.Date, &inst.StartTime, &inst.EndTime,
&inst.MinCapacity, &inst.MaxCapacity, &inst.Status, &inst.Year, &inst.Month,
&createdAt, &updatedAt); err != nil {
return nil, err
}
if templateID.Valid {
inst.TemplateID = &templateID.Int64
}
inst.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
inst.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
if inst.Volunteers == nil {
inst.Volunteers = []InstanceVolunteer{}
}
return &inst, nil
}
func daysIn(year, month int) int {
return time.Date(year, time.Month(month+1), 0, 0, 0, 0, 0, time.UTC).Day()
}