lots of changes
This commit is contained in:
+117
-16
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user