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
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.
+1
View File
@@ -4,3 +4,4 @@
*.db
*.db-shm
*.db-wal
/tmp
+1 -1
View File
@@ -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 \
+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
- 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
- SaturdayFriday 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
+116 -15
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)
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 {
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 {
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>
+58 -2
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>
<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 day</button>
<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}}