lots of changes

This commit is contained in:
2026-07-28 14:43:54 +03:30
parent b129d871c3
commit d94ba86953
13 changed files with 406 additions and 36 deletions
+117 -16
View File
@@ -3,7 +3,6 @@ package app
import (
"context"
"crypto/subtle"
"database/sql"
"embed"
"encoding/csv"
"encoding/json"
@@ -19,12 +18,16 @@ import (
"strconv"
"strings"
"time"
_ "time/tzdata"
"unicode/utf8"
"teammate/internal/jalali"
)
//go:embed templates/*.html static/*
// Embed the complete UI trees so the deployed binary never depends on
// template or static files being present beside it.
//
//go:embed templates static
var assets embed.FS
type Config struct {
@@ -32,6 +35,7 @@ type Config struct {
BaseURL string
DatabasePath string
SessionSecure bool
Timezone string
GitHubClientID string
GitHubClientSecret string
GoogleClientID string
@@ -44,6 +48,7 @@ func ConfigFromEnv() Config {
BaseURL: strings.TrimRight(env("APP_BASE_URL", "http://localhost:8080"), "/"),
DatabasePath: env("DATABASE_PATH", "./data/teammate.db"),
SessionSecure: env("SESSION_SECURE", "false") == "true",
Timezone: env("APP_TIMEZONE", "Asia/Tehran"),
GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"),
GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
@@ -85,6 +90,7 @@ type PageData struct {
SelectedSection string
Users []User
Day DayDetail
Week WeekDetail
WorkUpdates []WorkUpdate
ReportSummary []PersonReportSummary
ReportTotals ReportTotals
@@ -107,6 +113,7 @@ type DayDetail struct {
Jalali string
Weekday string
DayNote string
IsToday bool
Prev string
Next string
Present int
@@ -115,6 +122,18 @@ type DayDetail struct {
Members []RosterMember
}
type WeekDetail struct {
Enabled bool
StartJalali string
EndJalali string
Prev string
Next string
Present int
Remote int
Absent int
Days []DayDetail
}
type RosterMember struct {
User User
Status string
@@ -151,6 +170,14 @@ type CalendarCell struct {
}
func New(cfg Config) (*Server, error) {
if cfg.Timezone == "" {
cfg.Timezone = "Asia/Tehran"
}
location, err := time.LoadLocation(cfg.Timezone)
if err != nil {
return nil, fmt.Errorf("load application timezone %q: %w", cfg.Timezone, err)
}
time.Local = location
if err := os.MkdirAll(filepath.Dir(cfg.DatabasePath), 0o750); err != nil {
return nil, err
}
@@ -159,12 +186,7 @@ func New(cfg Config) (*Server, error) {
return nil, err
}
funcs := template.FuncMap{
"timeHM": func(t sql.NullTime) string {
if !t.Valid {
return "—"
}
return t.Time.Local().Format("15:04")
},
"timeHM": formatStoredClock,
"dateFA": func(raw string) string {
t, err := time.Parse("2006-01-02", raw)
if err != nil {
@@ -262,16 +284,69 @@ func (s *Server) dayPage(w http.ResponseWriter, r *http.Request) {
errorMessage = "Choose a valid Persian date."
}
selected, _ := time.Parse("2006-01-02", day)
view := r.URL.Query().Get("view")
if view != "week" {
view = "day"
}
var detail DayDetail
var week WeekDetail
if view == "week" {
offset := (int(selected.Weekday()) + 1) % 7
weekStart := selected.AddDate(0, 0, -offset)
week = WeekDetail{
Enabled: true,
StartJalali: jalali.FromTime(weekStart).String(),
EndJalali: jalali.FromTime(weekStart.AddDate(0, 0, 6)).String(),
Prev: jalali.FromTime(weekStart.AddDate(0, 0, -7)).String(),
Next: jalali.FromTime(weekStart.AddDate(0, 0, 7)).String(),
}
for index := 0; index < 7; index++ {
date := weekStart.AddDate(0, 0, index)
dayDetail, err := s.buildDayDetail(date.Format("2006-01-02"))
if err != nil {
http.Error(w, "could not load the team week view", http.StatusInternalServerError)
return
}
if dayDetail.Gregorian == day {
detail = dayDetail
}
week.Present += dayDetail.Present
week.Remote += dayDetail.Remote
week.Absent += dayDetail.Absent
week.Days = append(week.Days, dayDetail)
}
} else {
var err error
detail, err = s.buildDayDetail(day)
if err != nil {
http.Error(w, "could not load the team day view", http.StatusInternalServerError)
return
}
}
if detail.Gregorian == "" {
detail, _ = s.buildDayDetail(day)
}
s.render(w, "day.html", PageData{
Title: "Team " + view, User: currentUser(r), CSRF: csrfToken(r), Day: detail, Week: week,
Error: errorMessage, SelectedSection: "day",
})
}
func (s *Server) buildDayDetail(day string) (DayDetail, error) {
selected, err := time.Parse("2006-01-02", day)
if err != nil {
return DayDetail{}, err
}
rows, err := s.store.DayRoster(day)
if err != nil {
http.Error(w, "could not load the team day view", http.StatusInternalServerError)
return
return DayDetail{}, err
}
jalaliDate := jalali.FromTime(selected)
detail := DayDetail{
Gregorian: day,
Jalali: jalaliDate.String(),
Weekday: selected.Format("Monday"),
IsToday: day == time.Now().Format("2006-01-02"),
Prev: jalali.FromTime(selected.AddDate(0, 0, -1)).String(),
Next: jalali.FromTime(selected.AddDate(0, 0, 1)).String(),
}
@@ -315,22 +390,48 @@ func (s *Server) dayPage(w http.ResponseWriter, r *http.Request) {
}
detail.Members = append(detail.Members, member)
}
s.render(w, "day.html", PageData{
Title: "Team day", User: currentUser(r), CSRF: csrfToken(r), Day: detail,
Error: errorMessage, SelectedSection: "day",
})
return detail, nil
}
func attendanceDetail(checkIn, checkOut, location string) string {
if checkOut != "" {
return fmt.Sprintf("%s · %s%s", location, checkIn, checkOut)
return fmt.Sprintf("%s · %s%s", location, formatStoredClock(checkIn), formatStoredClock(checkOut))
}
if checkIn != "" {
return fmt.Sprintf("%s · checked in %s", location, checkIn)
return fmt.Sprintf("%s · checked in %s", location, formatStoredClock(checkIn))
}
return location
}
func formatStoredClock(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return "—"
}
if parsed, err := time.Parse(time.RFC3339Nano, raw); err == nil {
return parsed.In(time.Local).Format("15:04")
}
// Older rows were written from time.Time directly. SQLite persisted Go's
// String form, including a duplicate zone name and monotonic suffix:
// "2026-07-28 11:48:51.004 +0330 +0330 m=+18.1".
fields := strings.Fields(raw)
if len(fields) >= 3 {
legacy := strings.Join(fields[:3], " ")
for _, layout := range []string{
"2006-01-02 15:04:05.999999999 -0700",
"2006-01-02 15:04:05 -0700",
} {
if parsed, err := time.Parse(layout, legacy); err == nil {
return parsed.In(time.Local).Format("15:04")
}
}
}
if len(raw) >= 16 {
return raw[11:16]
}
return raw
}
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
defer cancel()