diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..4e4a095 --- /dev/null +++ b/.air.toml @@ -0,0 +1,59 @@ +#:schema https://json.schemastore.org/any.json + +env_files = [] +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] +args_bin = [] +bin = "./tmp/main" +cmd = "go build -o ./tmp/main ./cmd/server" +delay = 1000 +entrypoint = ["./tmp/main"] +exclude_dir = ["assets", "tmp", "vendor", "testdata"] +exclude_file = [] +exclude_regex = ["_test.go"] +exclude_unchanged = false +follow_symlink = false +full_bin = "" +ignore_dangerous_root_dir = false +include_dir = [] +include_ext = ["go", "tpl", "tmpl", "html"] +include_file = [] +kill_delay = "0s" +log = "build-errors.log" +poll = false +poll_interval = 0 +post_cmd = [] +pre_cmd = [] +rerun = false +rerun_delay = 500 +send_interrupt = false +stop_on_error = false + +[color] +app = "" +build = "yellow" +main = "magenta" +mode = "" +runner = "green" +watcher = "cyan" + +[log] +main_only = false +silent = false +time = false + +[misc] +clean_on_exit = false + +[proxy] +app_port = 0 +app_start_timeout = 0 +enabled = false +proxy_port = 0 + +[screen] +clear_on_rebuild = false +keep_scroll = true diff --git a/.env.example b/.env.example index 2e8e474..dcfd388 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ APP_ADDR=:8080 APP_BASE_URL=http://localhost:8080 DATABASE_PATH=./data/teammate.db SESSION_SECURE=false +APP_TIMEZONE=Asia/Tehran INITIAL_ADMIN_PASSWORD=admin123 # Optional OAuth providers. Local username/password works without these. diff --git a/.gitignore b/.gitignore index cedd4b5..72c8d90 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ *.db *.db-shm *.db-wal +/tmp diff --git a/Dockerfile b/Dockerfile index 0ce1a65..4869dc7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ WORKDIR /app COPY --from=build /out/teammate /usr/local/bin/teammate RUN mkdir -p /data && chown app:app /data USER app -ENV APP_ADDR=:8080 DATABASE_PATH=/data/teammate.db SESSION_SECURE=true +ENV APP_ADDR=:8080 DATABASE_PATH=/data/teammate.db APP_TIMEZONE=Asia/Tehran SESSION_SECURE=true EXPOSE 8080 VOLUME ["/data"] HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ diff --git a/README.md b/README.md index ab48abe..0b6a9f1 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Hamkar is a small team presence tracker built for startups that work with the Pe - Office and remote check-in/check-out - Persian month calendar with attendance, approved leave, remote days, Fridays, and fixed Solar Hijri public holidays - Team day detail showing who is present, remote, absent, or expected at the office +- Saturday–Friday team week schedule with calendar-style presence records - Period-based teammate work updates for completed, blocked, and pending tasks - Shared Trello-style team board with assignment, Jalali due dates, and drag-and-drop columns - Time-off and remote-day requests with a dependency-free Jalali date picker and typed Persian-date fallback @@ -40,6 +41,8 @@ password: admin123 Set `INITIAL_ADMIN_PASSWORD` before the first start to replace the demo password. It is only read when creating an empty database. The database is created at `./data/teammate.db`. +`APP_TIMEZONE` controls attendance dates and displayed check-in/out times. It defaults to `Asia/Tehran`, and timezone data is embedded in the Go binary so the same value is used inside minimal Docker images. + Environment files are not loaded automatically. Export values in your shell, use a process manager, or run: ```bash diff --git a/internal/app/server.go b/internal/app/server.go index c60343d..0c4857f 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -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() diff --git a/internal/app/server_test.go b/internal/app/server_test.go index 85d3548..7828e50 100644 --- a/internal/app/server_test.go +++ b/internal/app/server_test.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "testing" + "time" ) func TestLoginAttendanceAndReportFlow(t *testing.T) { @@ -85,6 +86,48 @@ func TestLoginAttendanceAndReportFlow(t *testing.T) { } } +func TestAttendanceTimesUseConfiguredTimezoneAndShowCheckout(t *testing.T) { + s, err := New(Config{ + Addr: ":0", + BaseURL: "http://example.test", + DatabasePath: filepath.Join(t.TempDir(), "timezone.db"), + Timezone: "Asia/Tehran", + }) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + day := time.Now().Format("2006-01-02") + legacyCheckIn := day + " 11:48:51.004976173 +0330 +0330 m=+18.184759346" + checkOut := day + " 11:49:32.496818296 +0330 +0330 m=+59.676601465" + if _, err := s.store.db.Exec( + `INSERT INTO attendance(user_id,day,check_in,check_out,mode) VALUES(?,?,?,?,?)`, + 1, day, legacyCheckIn, checkOut, "office", + ); err != nil { + t.Fatal(err) + } + token, _, err := s.store.CreateSession(1) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "/", nil) + request.AddCookie(&http.Cookie{Name: "teammate_session", Value: token}) + response := httptest.NewRecorder() + s.http.Handler.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("dashboard status: %d body %s", response.Code, response.Body.String()) + } + for _, expected := range []string{"11:48", "11:49", "Day complete"} { + if !strings.Contains(response.Body.String(), expected) { + t.Fatalf("dashboard does not include %q: %s", expected, response.Body.String()) + } + } + if strings.Contains(response.Body.String(), `action="/attendance/check-out"`) { + t.Fatal("completed attendance still shows the checkout action") + } +} + func TestPersianRequestDateParsing(t *testing.T) { got, ok := parseUserDate("1405-05-06") if !ok || got != "2026-07-28" { @@ -166,6 +209,9 @@ func TestPublicRegistrationAndIdentifierLogin(t *testing.T) { t.Fatalf("register page does not include %q", expected) } } + if !strings.Contains(registerView.Body.String(), `/static/favicon.svg`) { + t.Fatal("register page does not include the favicon") + } themeRequest := httptest.NewRequest(http.MethodGet, "/static/theme.js", nil) themeResponse := httptest.NewRecorder() @@ -174,6 +220,13 @@ func TestPublicRegistrationAndIdentifierLogin(t *testing.T) { t.Fatalf("theme asset: got %d, body %s", themeResponse.Code, themeResponse.Body.String()) } + faviconRequest := httptest.NewRequest(http.MethodGet, "/static/favicon.svg", nil) + faviconResponse := httptest.NewRecorder() + s.http.Handler.ServeHTTP(faviconResponse, faviconRequest) + if faviconResponse.Code != http.StatusOK || !strings.Contains(faviconResponse.Body.String(), `Saturday<") + friday := strings.Index(body, ">Friday<") + if saturday < 0 || friday < 0 || saturday >= friday { + t.Fatal("week page is not ordered Saturday through Friday") + } + for _, expected := range []string{"Workspace Admin", "Remote Teammate", "Absent Teammate", "Approved remote day", "Approved time off"} { + if !strings.Contains(body, expected) { + t.Fatalf("week page does not include %q", expected) + } + } } func TestWorkUpdateSubmissionFeedAndCSV(t *testing.T) { diff --git a/internal/app/static/favicon.svg b/internal/app/static/favicon.svg new file mode 100644 index 0000000..eebe2eb --- /dev/null +++ b/internal/app/static/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/internal/app/static/style.css b/internal/app/static/style.css index 97b7f25..9e3f517 100644 --- a/internal/app/static/style.css +++ b/internal/app/static/style.css @@ -320,6 +320,10 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb; .update-delete { text-align: right; } .day-page-head { align-items: center; } +.day-view-controls { display: flex; gap: 9px; align-items: end; } +.view-switch { display: flex; padding: 4px; border: 1px solid var(--line); border-radius: 11px; background: var(--white); box-shadow: var(--shadow); } +.view-switch a { display: grid; min-width: 52px; height: 37px; place-items: center; border-radius: 8px; color: var(--muted); font-size: .69rem; font-weight: 700; } +.view-switch a.active { background: var(--ink); color: var(--white); } .day-date-form { display: grid; grid-template-columns: 190px auto; gap: 9px; align-items: end; padding: 11px; border: 1px solid var(--line); border-radius: 13px; background: var(--white); box-shadow: var(--shadow); } .day-date-form input { margin-top: 4px; padding-top: 8px; padding-bottom: 8px; } .day-date-form .jalali-trigger { top: 10px; } @@ -356,6 +360,45 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb; .presence-badge.remote { background: #e9eff7; color: #4c6e96; } .presence-badge.absent { background: #f8e9e7; color: #a34d47; } +.week-layout { max-width: 1380px; margin: 0 auto; } +.week-calendar { overflow: hidden; } +.week-toolbar { display: flex; justify-content: space-between; align-items: center; min-height: 80px; padding: 16px 20px; border-bottom: 1px solid var(--line); } +.week-navigation { display: grid; grid-template-columns: 35px auto 35px; gap: 12px; align-items: center; } +.week-navigation > a { display: grid; width: 35px; height: 35px; place-items: center; border: 1px solid var(--line); border-radius: 9px; background: var(--white); font-size: 1.2rem; } +.week-navigation > a:hover { background: var(--paper); } +.week-navigation h2 { margin: 0; font-size: 1rem; } +.week-navigation .eyebrow { margin-bottom: 3px; font-size: .56rem; } +.week-totals { display: flex; gap: 15px; color: var(--muted); font-size: .62rem; } +.week-totals span { display: flex; gap: 6px; align-items: center; } +.week-totals strong { color: var(--ink); font-size: .78rem; } +.week-grid { display: grid; grid-template-columns: repeat(7, minmax(190px, 1fr)); overflow-x: auto; background: var(--line); gap: 1px; scrollbar-width: thin; } +.week-day-column { min-width: 190px; min-height: 480px; background: var(--white); } +.week-day-column.friday { background: #fcfaf6; } +.week-day-column.today { box-shadow: inset 0 3px 0 var(--green); } +.week-day-head { display: block; min-height: 92px; padding: 13px 12px 10px; border-bottom: 1px solid var(--line); text-align: center; } +.week-day-head:hover { background: var(--paper); } +.week-day-head > span { display: block; color: var(--muted); font-size: .56rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } +.week-day-head > strong { display: block; margin-top: 7px; font-size: .82rem; } +.week-day-head > small { display: block; overflow: hidden; margin-top: 3px; color: #929c97; font-size: .53rem; text-overflow: ellipsis; white-space: nowrap; } +.week-day-column.today .week-day-head > strong { color: var(--green); } +.week-day-counts { display: grid; grid-template-columns: repeat(3, 1fr); margin: 9px; border-radius: 8px; background: var(--paper); text-align: center; } +.week-day-counts span { padding: 5px 2px; font-size: .58rem; font-weight: 700; } +.week-day-counts .present { color: #347052; } +.week-day-counts .remote { color: #4c6e96; } +.week-day-counts .absent { color: #a34d47; } +.week-records { display: grid; gap: 6px; padding: 0 8px 12px; } +.week-record { position: relative; overflow: hidden; padding: 9px; border: 1px solid transparent; border-radius: 8px; } +.week-record.present { border-color: #b9d9c7; background: #e9f4ed; } +.week-record.remote { border-color: #c1d2e6; background: #eaf1f8; } +.week-record.absent { border-color: #e5c1bd; background: #f9eae8; } +.week-record-person { display: grid; grid-template-columns: 27px 1fr; gap: 7px; align-items: center; min-width: 0; } +.week-record-person .avatar { width: 27px; height: 27px; font-size: .59rem; } +.week-record-person img.avatar { display: block; object-fit: cover; } +.week-record-person strong { display: block; overflow: hidden; color: #25322c; font-size: .65rem; text-overflow: ellipsis; white-space: nowrap; } +.week-record-person small { display: block; overflow: hidden; margin-top: 2px; color: #69756f; font-size: .52rem; text-overflow: ellipsis; white-space: nowrap; } +.week-record-status { display: flex; gap: 5px; align-items: center; margin: 7px 0 0 34px; color: #5d6963; font-size: .51rem; font-weight: 700; text-transform: uppercase; } +.week-record-status .presence-mark { width: 6px; height: 6px; box-shadow: none; } + .login-page { display: grid; grid-template-columns: 1.06fr .94fr; min-height: 100vh; } .login-story { position: relative; display: flex; flex-direction: column; overflow: hidden; padding: 46px clamp(38px, 6vw, 85px); background: var(--green); color: #fff; } .login-story::before { position: absolute; right: -150px; bottom: -170px; width: 580px; height: 580px; border: 1px solid rgba(255,255,255,.1); border-radius: 50%; box-shadow: 0 0 0 80px rgba(255,255,255,.025), 0 0 0 160px rgba(255,255,255,.018); content: ""; } @@ -435,6 +478,14 @@ html[data-theme="dark"] .presence-mark.absent { box-shadow: 0 0 0 3px #4a2c2a; } html[data-theme="dark"] .presence-badge.present { background: #1d3b2c; color: #8fd0ae; } html[data-theme="dark"] .presence-badge.remote { background: #26394d; color: #9ab8dc; } html[data-theme="dark"] .presence-badge.absent { background: #432725; color: #efa09a; } +html[data-theme="dark"] .week-day-column { background: var(--white); } +html[data-theme="dark"] .week-day-column.friday { background: #201f19; } +html[data-theme="dark"] .week-record.present { border-color: #315c47; background: #1b3528; } +html[data-theme="dark"] .week-record.remote { border-color: #36516d; background: #24394e; } +html[data-theme="dark"] .week-record.absent { border-color: #67403d; background: #3a2422; } +html[data-theme="dark"] .week-record-person strong { color: #edf4f0; } +html[data-theme="dark"] .week-record-person small, +html[data-theme="dark"] .week-record-status { color: #aab7b0; } html[data-theme="dark"] .notice.success { border-color: #315c47; background: #183426; color: #91d1af; } html[data-theme="dark"] .notice.error { border-color: #67403d; background: #35201f; color: #f1a09a; } html[data-theme="dark"] .badge.pending { background: #453820; color: #e3bd72; } @@ -481,6 +532,9 @@ html[data-theme="dark"] .kanban-cards.drag-over { background: rgba(107, 172, 142 .updates-layout { grid-template-columns: 1fr; } .day-summary-card { grid-template-columns: 1fr; gap: 20px; } .day-stats { padding-top: 18px; border-top: 1px solid var(--line); border-left: 0; } + .day-page-head { display: block; } + .day-view-controls { margin-top: 18px; } + .week-grid { grid-template-columns: repeat(7, 210px); } .report-page-head { display: block; } .report-range-filter { width: max-content; max-width: 100%; margin-top: 18px; } .accumulated-totals { grid-template-columns: repeat(3, 1fr); } @@ -501,7 +555,10 @@ html[data-theme="dark"] .kanban-cards.drag-over { background: rgba(107, 172, 142 .main { padding: 26px 15px 90px; } .page-head { align-items: start; } .day-page-head { display: block; } + .day-view-controls { display: grid; grid-template-columns: auto 1fr; align-items: stretch; } + .view-switch { align-self: start; } .day-date-form { grid-template-columns: 1fr auto; margin-top: 20px; } + .day-view-controls .day-date-form { margin-top: 0; } .today-pill { display: none; } .stats-grid { grid-template-columns: repeat(3, minmax(0,1fr)); } .stat { padding: 12px; } @@ -539,6 +596,10 @@ html[data-theme="dark"] .kanban-cards.drag-over { background: rgba(107, 172, 142 .kanban-column { scroll-snap-align: start; } .filter-tabs { display: none; } .day-summary-card, .roster-card { padding: 18px; } + .week-toolbar { display: block; padding: 14px; } + .week-totals { margin-top: 13px; } + .week-grid { grid-template-columns: repeat(7, 82vw); scroll-snap-type: x mandatory; } + .week-day-column { min-width: 82vw; min-height: 400px; scroll-snap-align: start; } .day-date-nav { grid-template-columns: 33px 1fr 33px; gap: 9px; } .day-date-nav > a { width: 33px; height: 33px; } .day-stats > div { padding: 7px 10px; } diff --git a/internal/app/store.go b/internal/app/store.go index 70a82ba..0337738 100644 --- a/internal/app/store.go +++ b/internal/app/store.go @@ -33,8 +33,8 @@ type Attendance struct { ID int64 UserID int64 Day string - CheckIn sql.NullTime - CheckOut sql.NullTime + CheckIn string + CheckOut string Mode string } @@ -484,7 +484,9 @@ func (s *Store) DeleteSession(token string) { func (s *Store) TodayAttendance(userID int64, day string) (*Attendance, error) { var a Attendance - err := s.db.QueryRow(`SELECT id,user_id,day,check_in,check_out,mode FROM attendance WHERE user_id=? AND day=?`, userID, day). + err := s.db.QueryRow(`SELECT id,user_id,day, + COALESCE(CAST(check_in AS TEXT),''),COALESCE(CAST(check_out AS TEXT),''),mode + FROM attendance WHERE user_id=? AND day=?`, userID, day). Scan(&a.ID, &a.UserID, &a.Day, &a.CheckIn, &a.CheckOut, &a.Mode) if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -498,13 +500,13 @@ func (s *Store) CheckIn(userID int64, day, mode string) error { } _, err := s.db.Exec(`INSERT INTO attendance(user_id,day,check_in,mode) VALUES(?,?,?,?) ON CONFLICT(user_id,day) DO UPDATE SET check_in=COALESCE(attendance.check_in,excluded.check_in),mode=excluded.mode`, - userID, day, time.Now(), mode) + userID, day, time.Now().In(time.Local).Format(time.RFC3339Nano), mode) return err } func (s *Store) CheckOut(userID int64, day string) error { result, err := s.db.Exec(`UPDATE attendance SET check_out=? WHERE user_id=? AND day=? AND check_in IS NOT NULL AND check_out IS NULL`, - time.Now(), userID, day) + time.Now().In(time.Local).Format(time.RFC3339Nano), userID, day) if err != nil { return err } @@ -516,7 +518,8 @@ func (s *Store) CheckOut(userID int64, day string) error { } func (s *Store) AttendanceBetween(userID int64, start, end string) (map[string]Attendance, error) { - rows, err := s.db.Query(`SELECT id,user_id,day,check_in,check_out,mode FROM attendance + rows, err := s.db.Query(`SELECT id,user_id,day, + COALESCE(CAST(check_in AS TEXT),''),COALESCE(CAST(check_out AS TEXT),''),mode FROM attendance WHERE user_id=? AND day BETWEEN ? AND ?`, userID, start, end) if err != nil { return nil, err diff --git a/internal/app/templates/base.html b/internal/app/templates/base.html index 7334465..ec9a950 100644 --- a/internal/app/templates/base.html +++ b/internal/app/templates/base.html @@ -5,7 +5,9 @@ + {{.Title}} · Hamkar + diff --git a/internal/app/templates/dashboard.html b/internal/app/templates/dashboard.html index 17340f6..27a0ea6 100644 --- a/internal/app/templates/dashboard.html +++ b/internal/app/templates/dashboard.html @@ -8,8 +8,8 @@
-

TODAY’S PRESENCE

{{if .Attendance}}{{if .Attendance.CheckOut.Valid}}Day complete{{else}}You’re checked in{{end}}{{else}}Ready when you are{{end}}

- {{if and .Attendance (not .Attendance.CheckOut.Valid)}}ACTIVE{{else}}TODAY{{end}} +

TODAY’S PRESENCE

{{if .Attendance}}{{if .Attendance.CheckOut}}Day complete{{else}}You’re checked in{{end}}{{else}}Ready when you are{{end}}

+ {{if and .Attendance (not .Attendance.CheckOut)}}ACTIVE{{else}}TODAY{{end}}
{{if .Attendance}}
@@ -18,7 +18,7 @@
CHECKED OUT{{timeHM .Attendance.CheckOut}}
LOCATION{{if eq .Attendance.Mode "remote"}}Remote{{else}}Office{{end}}
- {{if not .Attendance.CheckOut.Valid}} + {{if not .Attendance.CheckOut}}
diff --git a/internal/app/templates/day.html b/internal/app/templates/day.html index d38ad96..8d9115b 100644 --- a/internal/app/templates/day.html +++ b/internal/app/templates/day.html @@ -1,18 +1,73 @@ {{define "day.html"}} {{template "shell-start" .}}
-

TEAM DAY

Who’s working?

See the team’s presence, remote work, and absences for one day.

- - - - +
+

{{if .Week.Enabled}}TEAM WEEK{{else}}TEAM DAY{{end}}

+

{{if .Week.Enabled}}Team schedule{{else}}Who’s working?{{end}}

+

{{if .Week.Enabled}}See everyone’s weekly office, remote, and absence plan.{{else}}See the team’s presence, remote work, and absences for one day.{{end}}

+
+
+ +
+ {{if .Week.Enabled}}{{end}} + + +
+
{{template "notice" .}} + +{{if .Week.Enabled}} +
+
+
+
+ +

SATURDAY — FRIDAY

{{.Week.StartJalali}} — {{.Week.EndJalali}}

+ +
+
+ {{.Week.Present}} Present + {{.Week.Remote}} Remote + {{.Week.Absent}} Absent +
+
+
+ {{range .Week.Days}} +
+ + {{.Weekday}} + {{.Jalali}} + {{.Gregorian}}{{if .DayNote}} · {{.DayNote}}{{end}} + +
+ {{.Present}}{{.Remote}}{{.Absent}} +
+
+ {{range .Members}} +
+
+ {{if .User.AvatarURL}}{{else}}{{initial .User.DisplayName}}{{end}} + {{.User.DisplayName}}{{.Detail}} +
+ {{.Label}} +
+ {{end}} +
+
+ {{end}} +
+
+
+{{else}}
@@ -52,5 +107,6 @@ {{end}}
+{{end}} {{template "shell-end" .}} {{end}}