lots of changes
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
/tmp
|
||||
|
||||
+1
-1
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
+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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 |
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<div class="dashboard-grid">
|
||||
<section class="card presence-card">
|
||||
<div class="presence-top">
|
||||
<div><p class="eyebrow">TODAY’S PRESENCE</p><h2>{{if .Attendance}}{{if .Attendance.CheckOut.Valid}}Day complete{{else}}You’re 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">TODAY’S PRESENCE</p><h2>{{if .Attendance}}{{if .Attendance.CheckOut}}Day complete{{else}}You’re 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>
|
||||
|
||||
@@ -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>Who’s working?</h1><p>See the team’s 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}}Who’s working?{{end}}</h1>
|
||||
<p>{{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}}</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}}
|
||||
|
||||
Reference in New Issue
Block a user