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
+59
View File
@@ -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
+1
View File
@@ -2,6 +2,7 @@ APP_ADDR=:8080
APP_BASE_URL=http://localhost:8080 APP_BASE_URL=http://localhost:8080
DATABASE_PATH=./data/teammate.db DATABASE_PATH=./data/teammate.db
SESSION_SECURE=false SESSION_SECURE=false
APP_TIMEZONE=Asia/Tehran
INITIAL_ADMIN_PASSWORD=admin123 INITIAL_ADMIN_PASSWORD=admin123
# Optional OAuth providers. Local username/password works without these. # Optional OAuth providers. Local username/password works without these.
+1
View File
@@ -4,3 +4,4 @@
*.db *.db
*.db-shm *.db-shm
*.db-wal *.db-wal
/tmp
+1 -1
View File
@@ -14,7 +14,7 @@ WORKDIR /app
COPY --from=build /out/teammate /usr/local/bin/teammate COPY --from=build /out/teammate /usr/local/bin/teammate
RUN mkdir -p /data && chown app:app /data RUN mkdir -p /data && chown app:app /data
USER app 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 EXPOSE 8080
VOLUME ["/data"] VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
+3
View File
@@ -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 - Office and remote check-in/check-out
- Persian month calendar with attendance, approved leave, remote days, Fridays, and fixed Solar Hijri public holidays - 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 - Team day detail showing who is present, remote, absent, or expected at the office
- SaturdayFriday team week schedule with calendar-style presence records
- Period-based teammate work updates for completed, blocked, and pending tasks - 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 - 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 - 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`. 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: Environment files are not loaded automatically. Export values in your shell, use a process manager, or run:
```bash ```bash
+116 -15
View File
@@ -3,7 +3,6 @@ package app
import ( import (
"context" "context"
"crypto/subtle" "crypto/subtle"
"database/sql"
"embed" "embed"
"encoding/csv" "encoding/csv"
"encoding/json" "encoding/json"
@@ -19,12 +18,16 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
_ "time/tzdata"
"unicode/utf8" "unicode/utf8"
"teammate/internal/jalali" "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 var assets embed.FS
type Config struct { type Config struct {
@@ -32,6 +35,7 @@ type Config struct {
BaseURL string BaseURL string
DatabasePath string DatabasePath string
SessionSecure bool SessionSecure bool
Timezone string
GitHubClientID string GitHubClientID string
GitHubClientSecret string GitHubClientSecret string
GoogleClientID string GoogleClientID string
@@ -44,6 +48,7 @@ func ConfigFromEnv() Config {
BaseURL: strings.TrimRight(env("APP_BASE_URL", "http://localhost:8080"), "/"), BaseURL: strings.TrimRight(env("APP_BASE_URL", "http://localhost:8080"), "/"),
DatabasePath: env("DATABASE_PATH", "./data/teammate.db"), DatabasePath: env("DATABASE_PATH", "./data/teammate.db"),
SessionSecure: env("SESSION_SECURE", "false") == "true", SessionSecure: env("SESSION_SECURE", "false") == "true",
Timezone: env("APP_TIMEZONE", "Asia/Tehran"),
GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"), GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"),
GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"), GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
@@ -85,6 +90,7 @@ type PageData struct {
SelectedSection string SelectedSection string
Users []User Users []User
Day DayDetail Day DayDetail
Week WeekDetail
WorkUpdates []WorkUpdate WorkUpdates []WorkUpdate
ReportSummary []PersonReportSummary ReportSummary []PersonReportSummary
ReportTotals ReportTotals ReportTotals ReportTotals
@@ -107,6 +113,7 @@ type DayDetail struct {
Jalali string Jalali string
Weekday string Weekday string
DayNote string DayNote string
IsToday bool
Prev string Prev string
Next string Next string
Present int Present int
@@ -115,6 +122,18 @@ type DayDetail struct {
Members []RosterMember 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 { type RosterMember struct {
User User User User
Status string Status string
@@ -151,6 +170,14 @@ type CalendarCell struct {
} }
func New(cfg Config) (*Server, error) { 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 { if err := os.MkdirAll(filepath.Dir(cfg.DatabasePath), 0o750); err != nil {
return nil, err return nil, err
} }
@@ -159,12 +186,7 @@ func New(cfg Config) (*Server, error) {
return nil, err return nil, err
} }
funcs := template.FuncMap{ funcs := template.FuncMap{
"timeHM": func(t sql.NullTime) string { "timeHM": formatStoredClock,
if !t.Valid {
return "—"
}
return t.Time.Local().Format("15:04")
},
"dateFA": func(raw string) string { "dateFA": func(raw string) string {
t, err := time.Parse("2006-01-02", raw) t, err := time.Parse("2006-01-02", raw)
if err != nil { if err != nil {
@@ -262,16 +284,69 @@ func (s *Server) dayPage(w http.ResponseWriter, r *http.Request) {
errorMessage = "Choose a valid Persian date." errorMessage = "Choose a valid Persian date."
} }
selected, _ := time.Parse("2006-01-02", day) selected, _ := time.Parse("2006-01-02", day)
rows, err := s.store.DayRoster(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 { if err != nil {
http.Error(w, "could not load the team day view", http.StatusInternalServerError) http.Error(w, "could not load the team day view", http.StatusInternalServerError)
return 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 {
return DayDetail{}, err
}
jalaliDate := jalali.FromTime(selected) jalaliDate := jalali.FromTime(selected)
detail := DayDetail{ detail := DayDetail{
Gregorian: day, Gregorian: day,
Jalali: jalaliDate.String(), Jalali: jalaliDate.String(),
Weekday: selected.Format("Monday"), Weekday: selected.Format("Monday"),
IsToday: day == time.Now().Format("2006-01-02"),
Prev: jalali.FromTime(selected.AddDate(0, 0, -1)).String(), Prev: jalali.FromTime(selected.AddDate(0, 0, -1)).String(),
Next: 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) detail.Members = append(detail.Members, member)
} }
s.render(w, "day.html", PageData{ return detail, nil
Title: "Team day", User: currentUser(r), CSRF: csrfToken(r), Day: detail,
Error: errorMessage, SelectedSection: "day",
})
} }
func attendanceDetail(checkIn, checkOut, location string) string { func attendanceDetail(checkIn, checkOut, location string) string {
if checkOut != "" { if checkOut != "" {
return fmt.Sprintf("%s · %s%s", location, checkIn, checkOut) return fmt.Sprintf("%s · %s%s", location, formatStoredClock(checkIn), formatStoredClock(checkOut))
} }
if checkIn != "" { if checkIn != "" {
return fmt.Sprintf("%s · checked in %s", location, checkIn) return fmt.Sprintf("%s · checked in %s", location, formatStoredClock(checkIn))
} }
return location 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) { func (s *Server) health(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), time.Second) ctx, cancel := context.WithTimeout(r.Context(), time.Second)
defer cancel() defer cancel()
+78
View File
@@ -9,6 +9,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time"
) )
func TestLoginAttendanceAndReportFlow(t *testing.T) { 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) { func TestPersianRequestDateParsing(t *testing.T) {
got, ok := parseUserDate("1405-05-06") got, ok := parseUserDate("1405-05-06")
if !ok || got != "2026-07-28" { if !ok || got != "2026-07-28" {
@@ -166,6 +209,9 @@ func TestPublicRegistrationAndIdentifierLogin(t *testing.T) {
t.Fatalf("register page does not include %q", expected) 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) themeRequest := httptest.NewRequest(http.MethodGet, "/static/theme.js", nil)
themeResponse := httptest.NewRecorder() 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()) 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{ register := formRequest(t, s.http.Handler, "/register", url.Values{
"display_name": {"Neda Karimi"}, "display_name": {"Neda Karimi"},
"email": {"neda@example.test"}, "email": {"neda@example.test"},
@@ -294,6 +347,31 @@ func TestTeamDayShowsPresentRemoteAndAbsent(t *testing.T) {
t.Fatalf("day page does not include %q", expected) 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) { 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; } .update-delete { text-align: right; }
.day-page-head { align-items: center; } .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 { 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 input { margin-top: 4px; padding-top: 8px; padding-bottom: 8px; }
.day-date-form .jalali-trigger { top: 10px; } .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.remote { background: #e9eff7; color: #4c6e96; }
.presence-badge.absent { background: #f8e9e7; color: #a34d47; } .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-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 { 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: ""; } .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.present { background: #1d3b2c; color: #8fd0ae; }
html[data-theme="dark"] .presence-badge.remote { background: #26394d; color: #9ab8dc; } 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"] .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.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"] .notice.error { border-color: #67403d; background: #35201f; color: #f1a09a; }
html[data-theme="dark"] .badge.pending { background: #453820; color: #e3bd72; } 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; } .updates-layout { grid-template-columns: 1fr; }
.day-summary-card { grid-template-columns: 1fr; gap: 20px; } .day-summary-card { grid-template-columns: 1fr; gap: 20px; }
.day-stats { padding-top: 18px; border-top: 1px solid var(--line); border-left: 0; } .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-page-head { display: block; }
.report-range-filter { width: max-content; max-width: 100%; margin-top: 18px; } .report-range-filter { width: max-content; max-width: 100%; margin-top: 18px; }
.accumulated-totals { grid-template-columns: repeat(3, 1fr); } .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; } .main { padding: 26px 15px 90px; }
.page-head { align-items: start; } .page-head { align-items: start; }
.day-page-head { display: block; } .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-date-form { grid-template-columns: 1fr auto; margin-top: 20px; }
.day-view-controls .day-date-form { margin-top: 0; }
.today-pill { display: none; } .today-pill { display: none; }
.stats-grid { grid-template-columns: repeat(3, minmax(0,1fr)); } .stats-grid { grid-template-columns: repeat(3, minmax(0,1fr)); }
.stat { padding: 12px; } .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; } .kanban-column { scroll-snap-align: start; }
.filter-tabs { display: none; } .filter-tabs { display: none; }
.day-summary-card, .roster-card { padding: 18px; } .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 { grid-template-columns: 33px 1fr 33px; gap: 9px; }
.day-date-nav > a { width: 33px; height: 33px; } .day-date-nav > a { width: 33px; height: 33px; }
.day-stats > div { padding: 7px 10px; } .day-stats > div { padding: 7px 10px; }
+9 -6
View File
@@ -33,8 +33,8 @@ type Attendance struct {
ID int64 ID int64
UserID int64 UserID int64
Day string Day string
CheckIn sql.NullTime CheckIn string
CheckOut sql.NullTime CheckOut string
Mode string Mode string
} }
@@ -484,7 +484,9 @@ func (s *Store) DeleteSession(token string) {
func (s *Store) TodayAttendance(userID int64, day string) (*Attendance, error) { func (s *Store) TodayAttendance(userID int64, day string) (*Attendance, error) {
var a Attendance 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) Scan(&a.ID, &a.UserID, &a.Day, &a.CheckIn, &a.CheckOut, &a.Mode)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil 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(?,?,?,?) _, 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`, 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 return err
} }
func (s *Store) CheckOut(userID int64, day string) error { 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`, 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 { if err != nil {
return err 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) { 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) WHERE user_id=? AND day BETWEEN ? AND ?`, userID, start, end)
if err != nil { if err != nil {
return nil, err return nil, err
+2
View File
@@ -5,7 +5,9 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light"> <meta name="color-scheme" content="light">
<meta name="theme-color" content="#285b48">
<title>{{.Title}} · Hamkar</title> <title>{{.Title}} · Hamkar</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<script src="/static/theme.js"></script> <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="https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/Vazirmatn-font-face.css">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
+3 -3
View File
@@ -8,8 +8,8 @@
<div class="dashboard-grid"> <div class="dashboard-grid">
<section class="card presence-card"> <section class="card presence-card">
<div class="presence-top"> <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> <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.Valid)}}ACTIVE{{else}}TODAY{{end}}</span> <span class="live-badge"><i></i> {{if and .Attendance (not .Attendance.CheckOut)}}ACTIVE{{else}}TODAY{{end}}</span>
</div> </div>
{{if .Attendance}} {{if .Attendance}}
<div class="time-line"> <div class="time-line">
@@ -18,7 +18,7 @@
<div><small>CHECKED OUT</small><strong>{{timeHM .Attendance.CheckOut}}</strong></div> <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><small>LOCATION</small><strong>{{if eq .Attendance.Mode "remote"}}Remote{{else}}Office{{end}}</strong></div>
</div> </div>
{{if not .Attendance.CheckOut.Valid}} {{if not .Attendance.CheckOut}}
<form method="post" action="/attendance/check-out"> <form method="post" action="/attendance/check-out">
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="button dark full">Check out</button> <button class="button dark full">Check out</button>
+58 -2
View File
@@ -1,18 +1,73 @@
{{define "day.html"}} {{define "day.html"}}
{{template "shell-start" .}} {{template "shell-start" .}}
<header class="page-head day-page-head"> <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> <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"> <form method="get" action="/day" class="day-date-form">
{{if .Week.Enabled}}<input type="hidden" name="view" value="week">{{end}}
<label>Persian date <label>Persian date
<span class="jalali-field"> <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}"> <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> <button type="button" class="jalali-trigger" aria-label="Choose team date" title="Open Persian calendar"></button>
</span> </span>
</label> </label>
<button class="button primary" type="submit">View day</button> <button class="button primary" type="submit">View</button>
</form> </form>
</div>
</header> </header>
{{template "notice" .}} {{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"> <div class="day-layout">
<section class="card day-summary-card"> <section class="card day-summary-card">
<div class="day-date-nav"> <div class="day-date-nav">
@@ -52,5 +107,6 @@
{{end}} {{end}}
</section> </section>
</div> </div>
{{end}}
{{template "shell-end" .}} {{template "shell-end" .}}
{{end}} {{end}}