feat: Hijri calendar, virtual midnight crossover, overlap rejection, WAL mode
- Hijri (قمری) calendar added as 3rd calendar option with tabular conversion - Calendar type select menu (like accent) with gregorian/jalali/hijri - Calendar type applied to exports and reports - Midnight crossover: virtual splits at computation time (no synthetic DB events) - Handles both UTC and local timezone boundaries - Timezone changes handled naturally (recomputed on-the-fly) - Rate limiting per user ID instead of chat ID - WAL mode enabled for SQLite - Smart reply keyboard updates after every state change - Overlapping time edits rejected (must preserve in/out alternation) - Single event deletion warns if timeline needs repair - Day off no longer prevents clock in/out - Accent colors shown with descriptive names in select menu - Delete button label changed to DEL for visual distinction
This commit is contained in:
@@ -26,6 +26,7 @@ type User struct {
|
||||
ReportTime string
|
||||
LastReportDate *string
|
||||
ExportAccent string
|
||||
Calendar string
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
@@ -67,6 +68,7 @@ func NewStore(path string) (*Store, error) {
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.Exec("PRAGMA journal_mode=WAL")
|
||||
if err := migrate(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -122,9 +124,12 @@ func hasColumn(db *sql.DB, table, column string) bool {
|
||||
|
||||
func migrate(db *sql.DB) error {
|
||||
if hasTable(db, "users") {
|
||||
return nil
|
||||
if !hasColumn(db, "users", "calendar") {
|
||||
slog.Info("migrating: adding calendar column to users")
|
||||
db.Exec("ALTER TABLE users ADD COLUMN calendar TEXT NOT NULL DEFAULT 'gregorian' CHECK (calendar IN ('gregorian', 'jalali', 'hijri'))")
|
||||
}
|
||||
}
|
||||
if hasTable(db, "events") && !hasColumn(db, "events", "user_id") {
|
||||
if hasTable(db, "events_old") || hasTable(db, "events") && !hasColumn(db, "events", "user_id") {
|
||||
slog.Info("migrating from old schema to new schema")
|
||||
if err := migrateFromOldSchema(db); err != nil {
|
||||
return err
|
||||
@@ -134,16 +139,27 @@ func migrate(db *sql.DB) error {
|
||||
}
|
||||
|
||||
func migrateFromOldSchema(db *sql.DB) error {
|
||||
// Rename old tables so CREATE TABLE IF NOT EXISTS can create new ones
|
||||
for _, t := range []string{"events", "days_off", "settings"} {
|
||||
if hasTable(db, t) {
|
||||
db.Exec("ALTER TABLE " + t + " RENAME TO " + t + "_old")
|
||||
}
|
||||
}
|
||||
|
||||
if err := execSchema(db, schemaPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return migrateFromOldSchemaData(db)
|
||||
}
|
||||
|
||||
func migrateFromOldSchemaData(db *sql.DB) error {
|
||||
migrateUsers(db)
|
||||
migrateDaysOff(db)
|
||||
migrateEvents(db)
|
||||
migrateSettings(db)
|
||||
|
||||
for _, t := range []string{"days_off", "settings", "events"} {
|
||||
for _, t := range []string{"days_off_old", "settings_old", "events_old"} {
|
||||
if hasTable(db, t) {
|
||||
db.Exec("DROP TABLE IF EXISTS " + t)
|
||||
}
|
||||
@@ -154,7 +170,10 @@ func migrateFromOldSchema(db *sql.DB) error {
|
||||
}
|
||||
|
||||
func migrateUsers(db *sql.DB) {
|
||||
rows, err := db.Query("SELECT DISTINCT chat_id FROM events")
|
||||
if !hasTable(db, "events_old") {
|
||||
return
|
||||
}
|
||||
rows, err := db.Query("SELECT DISTINCT chat_id FROM events_old")
|
||||
if err != nil {
|
||||
slog.Warn("migration: no events table to migrate users from")
|
||||
return
|
||||
@@ -174,10 +193,10 @@ func migrateUsers(db *sql.DB) {
|
||||
}
|
||||
|
||||
func migrateDaysOff(db *sql.DB) {
|
||||
if !hasTable(db, "days_off") {
|
||||
if !hasTable(db, "days_off_old") {
|
||||
return
|
||||
}
|
||||
rows, err := db.Query("SELECT chat_id, date, reason FROM days_off")
|
||||
rows, err := db.Query("SELECT chat_id, date, reason FROM days_off_old")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -202,10 +221,10 @@ func migrateDaysOff(db *sql.DB) {
|
||||
}
|
||||
|
||||
func migrateEvents(db *sql.DB) {
|
||||
if !hasTable(db, "events") {
|
||||
if !hasTable(db, "events_old") {
|
||||
return
|
||||
}
|
||||
rows, err := db.Query("SELECT chat_id, event_type, occurred_at, note, created_at FROM events ORDER BY occurred_at ASC")
|
||||
rows, err := db.Query("SELECT chat_id, event_type, occurred_at, note, created_at FROM events_old ORDER BY occurred_at ASC")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -239,10 +258,10 @@ func migrateEvents(db *sql.DB) {
|
||||
}
|
||||
|
||||
func migrateSettings(db *sql.DB) {
|
||||
if !hasTable(db, "settings") {
|
||||
if !hasTable(db, "settings_old") {
|
||||
return
|
||||
}
|
||||
rows, err := db.Query("SELECT chat_id, key, value FROM settings")
|
||||
rows, err := db.Query("SELECT chat_id, key, value FROM settings_old")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -290,14 +309,14 @@ func (s *Store) GetOrCreateUser(chatID int64) (*User, error) {
|
||||
func (s *Store) GetUserByChatID(chatID int64) (*User, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT id, chat_id, timezone, is_active, report_enabled, report_time,
|
||||
last_report_date, export_accent, created_at, updated_at
|
||||
last_report_date, export_accent, calendar, created_at, updated_at
|
||||
FROM users WHERE chat_id = ?
|
||||
`, chatID)
|
||||
var u User
|
||||
var lastReport sql.NullString
|
||||
err := row.Scan(
|
||||
&u.ID, &u.ChatID, &u.Timezone, &u.IsActive, &u.ReportEnabled, &u.ReportTime,
|
||||
&lastReport, &u.ExportAccent, &u.CreatedAt, &u.UpdatedAt,
|
||||
&lastReport, &u.ExportAccent, &u.Calendar, &u.CreatedAt, &u.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -310,8 +329,8 @@ func (s *Store) GetUserByChatID(chatID int64) (*User, error) {
|
||||
|
||||
func (s *Store) UpdateUser(user *User) error {
|
||||
_, err := s.db.Exec(
|
||||
"UPDATE users SET timezone=?, is_active=?, report_enabled=?, report_time=?, last_report_date=?, export_accent=?, updated_at=unixepoch() WHERE id=?",
|
||||
user.Timezone, user.IsActive, user.ReportEnabled, user.ReportTime, user.LastReportDate, user.ExportAccent, user.ID,
|
||||
"UPDATE users SET timezone=?, is_active=?, report_enabled=?, report_time=?, last_report_date=?, export_accent=?, calendar=?, updated_at=unixepoch() WHERE id=?",
|
||||
user.Timezone, user.IsActive, user.ReportEnabled, user.ReportTime, user.LastReportDate, user.ExportAccent, user.Calendar, user.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -327,7 +346,7 @@ func (s *Store) MarkReportSent(userID int64, date string) error {
|
||||
func (s *Store) GetAllUsers() ([]User, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, chat_id, timezone, is_active, report_enabled, report_time,
|
||||
last_report_date, export_accent, created_at, updated_at
|
||||
last_report_date, export_accent, calendar, created_at, updated_at
|
||||
FROM users
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -341,7 +360,7 @@ func (s *Store) GetAllUsers() ([]User, error) {
|
||||
var lastReport sql.NullString
|
||||
if err := rows.Scan(
|
||||
&u.ID, &u.ChatID, &u.Timezone, &u.IsActive, &u.ReportEnabled, &u.ReportTime,
|
||||
&lastReport, &u.ExportAccent, &u.CreatedAt, &u.UpdatedAt,
|
||||
&lastReport, &u.ExportAccent, &u.Calendar, &u.CreatedAt, &u.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user