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()
+78
View File
@@ -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(), `<svg`) {
t.Fatalf("favicon asset: got %d, body %s", faviconResponse.Code, faviconResponse.Body.String())
}
register := formRequest(t, s.http.Handler, "/register", url.Values{
"display_name": {"Neda Karimi"},
"email": {"neda@example.test"},
@@ -294,6 +347,31 @@ func TestTeamDayShowsPresentRemoteAndAbsent(t *testing.T) {
t.Fatalf("day page does not include %q", expected)
}
}
weekRequest := httptest.NewRequest(http.MethodGet, "/day?date="+selectedDay+"&view=week", nil)
weekRequest.AddCookie(&http.Cookie{Name: "teammate_session", Value: token})
weekResponse := httptest.NewRecorder()
s.http.Handler.ServeHTTP(weekResponse, weekRequest)
if weekResponse.Code != http.StatusOK {
t.Fatalf("week page status: %d, body %s", weekResponse.Code, weekResponse.Body.String())
}
body := weekResponse.Body.String()
if !strings.Contains(body, "TEAM WEEK") || !strings.Contains(body, "SATURDAY — FRIDAY") {
t.Fatalf("week page header is missing: %s", body)
}
if count := strings.Count(body, `class="week-day-column`); count != 7 {
t.Fatalf("week page has %d day columns, want 7", count)
}
saturday := strings.Index(body, ">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) {
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="17" fill="#285b48"/>
<path fill="#fff" d="M20.2 18.1h7v15.3c0 4.8 2.2 7.2 6.6 7.2 2.6 0 4.7-.8 6.2-2.3V18.1h7v28.2h-6.5v-3.1c-2.3 2.7-5.6 4.1-9.8 4.1-7 0-10.5-4-10.5-12V18.1Z"/>
<circle cx="23.7" cy="13.6" r="3.8" fill="#a9d3bf"/>
</svg>

After

Width:  |  Height:  |  Size: 338 B

+61
View File
@@ -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; }
+9 -6
View File
@@ -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
+2
View File
@@ -5,7 +5,9 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
<meta name="theme-color" content="#285b48">
<title>{{.Title}} · Hamkar</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<script src="/static/theme.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/Vazirmatn-font-face.css">
<link rel="stylesheet" href="/static/style.css">
+3 -3
View File
@@ -8,8 +8,8 @@
<div class="dashboard-grid">
<section class="card presence-card">
<div class="presence-top">
<div><p class="eyebrow">TODAYS PRESENCE</p><h2>{{if .Attendance}}{{if .Attendance.CheckOut.Valid}}Day complete{{else}}Youre checked in{{end}}{{else}}Ready when you are{{end}}</h2></div>
<span class="live-badge"><i></i> {{if and .Attendance (not .Attendance.CheckOut.Valid)}}ACTIVE{{else}}TODAY{{end}}</span>
<div><p class="eyebrow">TODAYS PRESENCE</p><h2>{{if .Attendance}}{{if .Attendance.CheckOut}}Day complete{{else}}Youre checked in{{end}}{{else}}Ready when you are{{end}}</h2></div>
<span class="live-badge"><i></i> {{if and .Attendance (not .Attendance.CheckOut)}}ACTIVE{{else}}TODAY{{end}}</span>
</div>
{{if .Attendance}}
<div class="time-line">
@@ -18,7 +18,7 @@
<div><small>CHECKED OUT</small><strong>{{timeHM .Attendance.CheckOut}}</strong></div>
<div><small>LOCATION</small><strong>{{if eq .Attendance.Mode "remote"}}Remote{{else}}Office{{end}}</strong></div>
</div>
{{if not .Attendance.CheckOut.Valid}}
{{if not .Attendance.CheckOut}}
<form method="post" action="/attendance/check-out">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="button dark full">Check out</button>
+66 -10
View File
@@ -1,18 +1,73 @@
{{define "day.html"}}
{{template "shell-start" .}}
<header class="page-head day-page-head">
<div><p class="eyebrow">TEAM DAY</p><h1>Whos working?</h1><p>See the teams presence, remote work, and absences for one day.</p></div>
<form method="get" action="/day" class="day-date-form">
<label>Persian date
<span class="jalali-field">
<input name="date" value="{{.Day.Jalali}}" data-jalali-picker required autocomplete="off" inputmode="numeric" pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}">
<button type="button" class="jalali-trigger" aria-label="Choose team date" title="Open Persian calendar"></button>
</span>
</label>
<button class="button primary" type="submit">View day</button>
</form>
<div>
<p class="eyebrow">{{if .Week.Enabled}}TEAM WEEK{{else}}TEAM DAY{{end}}</p>
<h1>{{if .Week.Enabled}}Team schedule{{else}}Whos working?{{end}}</h1>
<p>{{if .Week.Enabled}}See everyones weekly office, remote, and absence plan.{{else}}See the teams presence, remote work, and absences for one day.{{end}}</p>
</div>
<div class="day-view-controls">
<nav class="view-switch" aria-label="Schedule view">
<a href="/day?date={{.Day.Jalali}}" class="{{if not .Week.Enabled}}active{{end}}">Day</a>
<a href="/day?date={{.Day.Jalali}}&view=week" class="{{if .Week.Enabled}}active{{end}}">Week</a>
</nav>
<form method="get" action="/day" class="day-date-form">
{{if .Week.Enabled}}<input type="hidden" name="view" value="week">{{end}}
<label>Persian date
<span class="jalali-field">
<input name="date" value="{{.Day.Jalali}}" data-jalali-picker required autocomplete="off" inputmode="numeric" pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}">
<button type="button" class="jalali-trigger" aria-label="Choose team date" title="Open Persian calendar"></button>
</span>
</label>
<button class="button primary" type="submit">View</button>
</form>
</div>
</header>
{{template "notice" .}}
{{if .Week.Enabled}}
<div class="week-layout">
<section class="card week-calendar">
<header class="week-toolbar">
<div class="week-navigation">
<a href="/day?date={{.Week.Prev}}&view=week" aria-label="Previous week"></a>
<div><p class="eyebrow">SATURDAY — FRIDAY</p><h2>{{.Week.StartJalali}} — {{.Week.EndJalali}}</h2></div>
<a href="/day?date={{.Week.Next}}&view=week" aria-label="Next week"></a>
</div>
<div class="week-totals">
<span><i class="presence-mark present"></i><strong>{{.Week.Present}}</strong> Present</span>
<span><i class="presence-mark remote"></i><strong>{{.Week.Remote}}</strong> Remote</span>
<span><i class="presence-mark absent"></i><strong>{{.Week.Absent}}</strong> Absent</span>
</div>
</header>
<div class="week-grid">
{{range .Week.Days}}
<section class="week-day-column {{if .IsToday}}today{{end}} {{if eq .Weekday "Friday"}}friday{{end}}">
<a class="week-day-head" href="/day?date={{.Jalali}}">
<span>{{.Weekday}}</span>
<strong>{{.Jalali}}</strong>
<small>{{.Gregorian}}{{if .DayNote}} · {{.DayNote}}{{end}}</small>
</a>
<div class="week-day-counts">
<span class="present">{{.Present}}</span><span class="remote">{{.Remote}}</span><span class="absent">{{.Absent}}</span>
</div>
<div class="week-records">
{{range .Members}}
<article class="week-record {{.Status}}">
<div class="week-record-person">
{{if .User.AvatarURL}}<img class="avatar" src="{{.User.AvatarURL}}" alt="">{{else}}<span class="avatar">{{initial .User.DisplayName}}</span>{{end}}
<span><strong>{{.User.DisplayName}}</strong><small>{{.Detail}}</small></span>
</div>
<span class="week-record-status"><i class="presence-mark {{.Status}}"></i>{{.Label}}</span>
</article>
{{end}}
</div>
</section>
{{end}}
</div>
</section>
</div>
{{else}}
<div class="day-layout">
<section class="card day-summary-card">
<div class="day-date-nav">
@@ -52,5 +107,6 @@
{{end}}
</section>
</div>
{{end}}
{{template "shell-end" .}}
{{end}}