diff --git a/.gitignore b/.gitignore index e219262..657d18e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ *.dylib mindloop mindloop-server +server # Test binary, built with `go test -c` *.test diff --git a/api/v1/handlers_ai.go b/api/v1/handlers_ai.go new file mode 100644 index 0000000..9e2d571 --- /dev/null +++ b/api/v1/handlers_ai.go @@ -0,0 +1,118 @@ +package v1 + +import ( + "encoding/json" + "net/http" + + "github.com/snehmatic/mindloop/internal/core/ai" + "github.com/snehmatic/mindloop/internal/core/summary" + "github.com/snehmatic/mindloop/internal/utils" +) + +type AISettingsRequest struct { + Provider string `json:"provider"` + Model string `json:"model"` + Token string `json:"token"` +} + +func (mlh *MindloopHandler) HandleGetAISettings(w http.ResponseWriter, r *http.Request) { + // Re-initialize AI service with current DB + aiService := ai.NewService(mlh.journal.DB) // hack: accessing DB via a service that has it + provider, model, token, _ := aiService.GetSettings() + + hasToken := token != "" + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "provider": provider, + "model": model, + "hasToken": hasToken, + }) +} + +func (mlh *MindloopHandler) HandleSaveAISettings(w http.ResponseWriter, r *http.Request) { + var req AISettingsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request payload", http.StatusBadRequest) + return + } + + aiService := ai.NewService(mlh.journal.DB) + if err := aiService.SaveSettings(req.Provider, req.Model, req.Token); err != nil { + http.Error(w, "Failed to save settings: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{"message": "Settings saved successfully"}) +} + +func (mlh *MindloopHandler) HandleListAIModels(w http.ResponseWriter, r *http.Request) { + aiService := ai.NewService(mlh.journal.DB) + models, err := aiService.ListModels() + if err != nil { + http.Error(w, "Failed to list models: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "models": models, + }) +} + +func (mlh *MindloopHandler) HandleTestAIConnection(w http.ResponseWriter, r *http.Request) { + var req AISettingsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request payload", http.StatusBadRequest) + return + } + + aiService := ai.NewService(mlh.journal.DB) + + // If token is empty in the request, fallback to the saved token + if req.Token == "" { + _, _, savedToken, _ := aiService.GetSettings() + req.Token = savedToken + } + + if err := aiService.TestConnection(req.Provider, req.Model, req.Token); err != nil { + http.Error(w, "Connection test failed: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{"message": "Connection successful!"}) +} + +func (mlh *MindloopHandler) HandleGenerateAIJournal(w http.ResponseWriter, r *http.Request) { + period := r.URL.Query().Get("period") + if period == "" { + period = "daily" + } + + start, end := utils.GetDateRange(period) + summaryService := summary.NewService(mlh.journal.DB) + report, err := summaryService.GenerateSummary(start, end) + if err != nil { + http.Error(w, "Failed to generate summary data", http.StatusInternalServerError) + return + } + + aiService := ai.NewService(mlh.journal.DB) + generatedText, err := aiService.GenerateJournal(report) + if err != nil { + http.Error(w, "AI Generation failed: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{ + "content": generatedText, + "title": "AI Summary: " + report.DateRange, + }) +} diff --git a/cmd/cli/journal.go b/cmd/cli/journal.go index 39f1073..68a9244 100644 --- a/cmd/cli/journal.go +++ b/cmd/cli/journal.go @@ -2,9 +2,12 @@ package cli import ( "fmt" + "time" cfg "github.com/snehmatic/mindloop/internal/config" + "github.com/snehmatic/mindloop/internal/core/ai" "github.com/snehmatic/mindloop/internal/core/journal" + "github.com/snehmatic/mindloop/internal/core/summary" "github.com/snehmatic/mindloop/internal/utils" "github.com/snehmatic/mindloop/models" "github.com/spf13/cobra" @@ -15,6 +18,79 @@ var ( journalService *journal.Service ) +func getJournalTimeRange(period string) (time.Time, time.Time) { + now := time.Now() + switch period { + case "yearly": + return time.Date(now.Year()-1, now.Month(), now.Day(), 0, 0, 0, 0, now.Location()), now + case "weekly": + end := time.Now() + start := end.AddDate(0, 0, -7) + start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location()) + return start, end + case "daily": + return now.Add(-24 * time.Hour), now + default: + return now.Add(-24 * time.Hour), now + } +} + +var generateCmd = &cobra.Command{ + Use: "generate", + Short: "Auto-generate a journal entry using AI", + Example: `mindloop journal generate -w`, + Run: func(cmd *cobra.Command, args []string) { + weekly, _ := cmd.Flags().GetBool("weekly") + yearly, _ := cmd.Flags().GetBool("yearly") + + period := "daily" + if weekly { + period = "weekly" + } else if yearly { + period = "yearly" + } + + start, end := getJournalTimeRange(period) + + summarySvc := summary.NewService(gdb) + report, err := summarySvc.GenerateSummary(start, end) + if err != nil { + utils.PrintErrorln("Failed to generate summary report:", err) + return + } + + utils.PrintLoadingln("✨ Generating AI journal entry...") + aiService := ai.NewService(gdb) + generatedText, err := aiService.GenerateJournal(report) + if err != nil { + utils.PrintErrorln("Failed to generate journal:", err) + return + } + + fmt.Println("\n" + generatedText + "\n") + + fmt.Print("Would you like to save this into the journal? (y/N): ") + var response string + _, _ = fmt.Scanln(&response) + if response == "y" || response == "Y" { + title := fmt.Sprintf("AI Summary: %s", report.DateRange) + // Assuming journal points default to 5 if config isn't read fully + uc := cfg.UserConfig{} + _ = uc.ReadFromYAML() + pts := uc.PointsConfig.Journal + if pts == 0 { + pts = 5 + } + _, err = journalService.CreateEntry(title, generatedText, "reflective", pts) + if err != nil { + utils.PrintErrorln("Failed to save journal:", err) + } else { + utils.PrintSuccessln("Saved successfully!") + } + } + }, +} + var journalCmd = &cobra.Command{ Use: "journal", Short: "Journal your thoughts and progress", @@ -159,6 +235,12 @@ func init() { journalCmd.AddCommand(journalListCmd) journalCmd.AddCommand(journalViewCmd) journalCmd.AddCommand(journalDeleteCmd) + + generateCmd.Flags().BoolP("daily", "d", false, "Generate daily summary") + generateCmd.Flags().BoolP("weekly", "w", false, "Generate weekly summary") + generateCmd.Flags().BoolP("yearly", "y", false, "Generate yearly summary") + journalCmd.AddCommand(generateCmd) + rootCmd.AddCommand(journalCmd) mood = journalNewCmd.Flags().StringP("mood", "m", "neutral", "Set journal mood") diff --git a/cmd/cli/summary.go b/cmd/cli/summary.go index 1fcd8e1..e1c5fa9 100644 --- a/cmd/cli/summary.go +++ b/cmd/cli/summary.go @@ -45,6 +45,7 @@ var summaryCmd = &cobra.Command{ ac.Logger.Info().Msgf("Generated summary from %s to %s", start.Format("02-Jan-2006"), end.Format("02-Jan-2006")) utils.PrintSuccessln("Summary generated successfully!") + utils.PrintInfoln("\nšŸ’” Tip: For an AI-generated overview, use `mindloop journal generate -d` (or -w, -y)") utils.PrintInfoln("You can also use -d, -w, -m, or -y flags to specify the time range for these summaries. (-d is default)") }, } diff --git a/cmd/server/server.go b/cmd/server/server.go index 9a48c54..fd39aab 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -103,6 +103,13 @@ func CreateRouter(mlh *v1.MindloopHandler) *mux.Router { r.HandleFunc("/settings", mlh.HandleSettings).Methods("GET") r.HandleFunc("/settings/update", mlh.HandleSettingsUpdate).Methods("POST") + // AI Routes + r.HandleFunc("/api/v1/ai/settings", mlh.HandleGetAISettings).Methods("GET") + r.HandleFunc("/api/v1/ai/settings", mlh.HandleSaveAISettings).Methods("POST") + r.HandleFunc("/api/v1/ai/models", mlh.HandleListAIModels).Methods("GET") + r.HandleFunc("/api/v1/ai/test", mlh.HandleTestAIConnection).Methods("POST") + r.HandleFunc("/api/v1/ai/generate", mlh.HandleGenerateAIJournal).Methods("GET") + // Backup Routes r.HandleFunc("/backup/export", mlh.HandleBackupExport).Methods("GET") r.HandleFunc("/backup/import", mlh.HandleBackupImport).Methods("POST") diff --git a/db/db.go b/db/db.go index ac9c453..ad9db1a 100644 --- a/db/db.go +++ b/db/db.go @@ -109,6 +109,7 @@ func MigrateDB(db *gorm.DB) error { &models.Routine{}, &models.Task{}, &models.SubTask{}, + &models.AppSetting{}, ) if err != nil { logger.Error().Err(err).Msg("Failed to migrate DB") diff --git a/internal/core/ai/ai.go b/internal/core/ai/ai.go new file mode 100644 index 0000000..43dce35 --- /dev/null +++ b/internal/core/ai/ai.go @@ -0,0 +1,318 @@ +package ai + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/snehmatic/mindloop/internal/utils" + "github.com/snehmatic/mindloop/models" + "gorm.io/gorm" +) + +const ( + SettingKeyAIProvider = "ai_provider" + SettingKeyAIModel = "ai_model" + SettingKeyAIToken = "ai_token" +) + +type Service struct { + DB *gorm.DB +} + +func NewService(db *gorm.DB) *Service { + return &Service{DB: db} +} + +// GetSettings retrieves the AI configuration from the database +func (s *Service) GetSettings() (provider, model, token string, err error) { + var pSetting, mSetting, tSetting models.AppSetting + s.DB.Where("key = ?", SettingKeyAIProvider).Limit(1).Find(&pSetting) + s.DB.Where("key = ?", SettingKeyAIModel).Limit(1).Find(&mSetting) + s.DB.Where("key = ?", SettingKeyAIToken).Limit(1).Find(&tSetting) + + provider = pSetting.Value + model = mSetting.Value + + // Token from DB overrides env var, if exists + envToken := os.Getenv("MINDLOOP_AI_TOKEN") + if tSetting.Value != "" { + decrypted, err := utils.Decrypt(tSetting.Value) + if err == nil { + token = strings.TrimSpace(decrypted) + } + } else if envToken != "" { + token = strings.TrimSpace(envToken) + } + + if provider == "" { + provider = "gemini" // default + } + return provider, model, token, nil +} + +// SaveSettings encrypts the token and saves the configuration +func (s *Service) SaveSettings(provider, model, token string) error { + s.saveOrUpdate(SettingKeyAIProvider, provider) + s.saveOrUpdate(SettingKeyAIModel, model) + + if token != "" { + encrypted, err := utils.Encrypt(token) + if err != nil { + return err + } + s.saveOrUpdate(SettingKeyAIToken, encrypted) + } + return nil +} + +func (s *Service) saveOrUpdate(key, value string) { + var setting models.AppSetting + result := s.DB.Where("key = ?", key).Limit(1).Find(&setting) + if result.RowsAffected == 0 { + s.DB.Create(&models.AppSetting{Key: key, Value: value}) + } else { + setting.Value = value + s.DB.Save(&setting) + } +} + +// ListModels fetches the available models for the configured provider +func (s *Service) ListModels() ([]string, error) { + provider, _, token, _ := s.GetSettings() + if token == "" { + return nil, fmt.Errorf("AI token not configured") + } + + switch provider { + case "openai": + return s.listOpenAIModels(token) + case "anthropic": + return nil, fmt.Errorf("anthropic support coming soon") + default: + return s.listGeminiModels(token) + } +} + +func (s *Service) listGeminiModels(token string) ([]string, error) { + url := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models?key=%s", token) + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("gemini API error (%d): %s", resp.StatusCode, string(body)) + } + + var result struct { + Models []struct { + Name string `json:"name"` + SupportedGenerationMethods []string `json:"supportedGenerationMethods"` + } `json:"models"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + var models []string + for _, m := range result.Models { + for _, method := range m.SupportedGenerationMethods { + if method == "generateContent" { + models = append(models, strings.TrimPrefix(m.Name, "models/")) + break + } + } + } + return models, nil +} + +func (s *Service) listOpenAIModels(token string) ([]string, error) { + url := "https://api.openai.com/v1/models" + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("openAI API error (%d): %s", resp.StatusCode, string(body)) + } + + var result struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + var models []string + for _, m := range result.Data { + if strings.HasPrefix(m.ID, "gpt-") || strings.HasPrefix(m.ID, "o1-") { + models = append(models, m.ID) + } + } + return models, nil +} +// TestConnection sends a minimal prompt to verify the configuration works +func (s *Service) TestConnection(provider, model, token string) error { + if token == "" { + return fmt.Errorf("AI token not configured") + } + + testData := `{"test": true}` + + var err error + switch provider { + case "openai": + _, err = s.generateOpenAI(model, token, testData) + case "anthropic": + _, err = s.generateAnthropic(model, token, testData) + default: + _, err = s.generateGemini(model, token, testData) + } + return err +} + +func (s *Service) GenerateJournal(summary models.SummaryReport) (string, error) { + provider, model, token, _ := s.GetSettings() + if token == "" { + return "", fmt.Errorf("AI token not configured. Set MINDLOOP_AI_TOKEN or configure via UI settings") + } + + dataBytes, err := json.Marshal(summary) + if err != nil { + return "", err + } + + switch provider { + case "openai": + return s.generateOpenAI(model, token, string(dataBytes)) + case "anthropic": + return s.generateAnthropic(model, token, string(dataBytes)) + default: + // Default to gemini format + return s.generateGemini(model, token, string(dataBytes)) + } +} + +func (s *Service) generateGemini(model, token, contextData string) (string, error) { + if model == "" { + model = "gemini-1.5-flash-latest" + } + model = strings.TrimPrefix(model, "models/") + url := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s", model, token) + + reqBody := map[string]interface{}{ + "system_instruction": map[string]interface{}{ + "parts": map[string]interface{}{"text": JournalSystemPrompt}, + }, + "contents": []map[string]interface{}{ + { + "parts": []map[string]interface{}{ + {"text": "Here is my activity summary data:\n" + contextData}, + }, + }, + }, + } + + bodyBytes, _ := json.Marshal(reqBody) + resp, err := http.Post(url, "application/json", bytes.NewBuffer(bodyBytes)) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("gemini API error (%d): %s", resp.StatusCode, string(body)) + } + + var result struct { + Candidates []struct { + Content struct { + Parts []struct { + Text string `json:"text"` + } `json:"parts"` + } `json:"content"` + } `json:"candidates"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + if len(result.Candidates) > 0 && len(result.Candidates[0].Content.Parts) > 0 { + return result.Candidates[0].Content.Parts[0].Text, nil + } + + return "", fmt.Errorf("no content generated") +} + +func (s *Service) generateOpenAI(model, token, contextData string) (string, error) { + if model == "" { + model = "gpt-4o-mini" + } + url := "https://api.openai.com/v1/chat/completions" + + reqBody := map[string]interface{}{ + "model": model, + "messages": []map[string]interface{}{ + {"role": "system", "content": JournalSystemPrompt}, + {"role": "user", "content": "Here is my activity summary data:\n" + contextData}, + }, + } + + bodyBytes, _ := json.Marshal(reqBody) + req, _ := http.NewRequest("POST", url, bytes.NewBuffer(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("openAI API error (%d): %s", resp.StatusCode, string(body)) + } + + var result struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + if len(result.Choices) > 0 { + return result.Choices[0].Message.Content, nil + } + return "", fmt.Errorf("no content generated") +} + +// Stub for Anthropic to prevent compile errors, implemented simply +func (s *Service) generateAnthropic(model, token, contextData string) (string, error) { + // Simple stub for v1 + return "", fmt.Errorf("anthropic support coming soon") +} diff --git a/internal/core/ai/prompts.go b/internal/core/ai/prompts.go new file mode 100644 index 0000000..305272a --- /dev/null +++ b/internal/core/ai/prompts.go @@ -0,0 +1,16 @@ +package ai + +const JournalSystemPrompt = `You are the user's personal journaling assistant. Your only job is to write the final journal entry. + +Do NOT output your internal thought process, do NOT repeat your instructions, do NOT output headers like "Input:" or "Output:" or "Role:". + +Write directly to the user in a friendly, empathetic, and encouraging tone. The user has ADHD, so they benefit from positive reinforcement and clear, digestible summaries of their day/week. + +Using the provided JSON activity data, write a cohesive journal entry. +Structure: +1. A warm opening acknowledging their effort. +2. A bulleted list of their key "Wins" (completed tasks, high focus times, perfect habits). +3. A brief observation on their patterns (e.g., "You did a lot of short bursts of focus today!"). +4. A closing thought or gentle question to help them reflect. + +Do not mention the raw JSON, IDs, or point values unnecessarily. Just write the journal entry itself.` diff --git a/internal/server/server.go b/internal/server/server.go index 58b88a1..8ba1ac5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -89,6 +89,13 @@ func CreateRouter(mlh *v1.MindloopHandler) *mux.Router { r.HandleFunc("/settings/update", mlh.HandleSettingsUpdate).Methods("POST") r.HandleFunc("/settings/update-width", mlh.HandleSettingsUpdateWidth).Methods("POST") + // AI Routes + r.HandleFunc("/api/v1/ai/settings", mlh.HandleGetAISettings).Methods("GET") + r.HandleFunc("/api/v1/ai/settings", mlh.HandleSaveAISettings).Methods("POST") + r.HandleFunc("/api/v1/ai/models", mlh.HandleListAIModels).Methods("GET") + r.HandleFunc("/api/v1/ai/test", mlh.HandleTestAIConnection).Methods("POST") + r.HandleFunc("/api/v1/ai/generate", mlh.HandleGenerateAIJournal).Methods("GET") + // Backup Routes r.HandleFunc("/backup/export", mlh.HandleBackupExport).Methods("GET") r.HandleFunc("/backup/import", mlh.HandleBackupImport).Methods("POST") diff --git a/internal/utils/crypto.go b/internal/utils/crypto.go new file mode 100644 index 0000000..2d3e3a8 --- /dev/null +++ b/internal/utils/crypto.go @@ -0,0 +1,74 @@ +package utils + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "io" + "os" +) + +func getEncryptionKey() []byte { + key := os.Getenv("MINDLOOP_ENC_KEY") + if key == "" { + key = "mindloop-default-secret-key-32b!" // 32 bytes + } + // ensure exactly 32 bytes + if len(key) < 32 { + padding := make([]byte, 32-len(key)) + key = key + string(padding) + } + return []byte(key[:32]) +} + +// Encrypt encrypts a string using AES-GCM and returns a base64 encoded string +func Encrypt(text string) (string, error) { + if text == "" { + return "", nil + } + block, err := aes.NewCipher(getEncryptionKey()) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + ciphertext := gcm.Seal(nonce, nonce, []byte(text), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// Decrypt decrypts a base64 encoded string using AES-GCM +func Decrypt(cryptoText string) (string, error) { + if cryptoText == "" { + return "", nil + } + ciphertext, err := base64.StdEncoding.DecodeString(cryptoText) + if err != nil { + return "", err + } + block, err := aes.NewCipher(getEncryptionKey()) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return "", errors.New("ciphertext too short") + } + nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + return string(plaintext), nil +} diff --git a/internal/utils/crypto_test.go b/internal/utils/crypto_test.go new file mode 100644 index 0000000..4c31ccf --- /dev/null +++ b/internal/utils/crypto_test.go @@ -0,0 +1,40 @@ +package utils + +import ( + "testing" +) + +func TestEncryptDecrypt(t *testing.T) { + original := "my-secret-api-token" + encrypted, err := Encrypt(original) + if err != nil { + t.Fatalf("Failed to encrypt: %v", err) + } + if encrypted == original { + t.Fatalf("Encrypted text is same as original") + } + decrypted, err := Decrypt(encrypted) + if err != nil { + t.Fatalf("Failed to decrypt: %v", err) + } + if decrypted != original { + t.Fatalf("Expected %s, got %s", original, decrypted) + } +} + +func TestEncryptDecryptEmpty(t *testing.T) { + encrypted, err := Encrypt("") + if err != nil { + t.Fatalf("Failed to encrypt empty string: %v", err) + } + if encrypted != "" { + t.Fatalf("Expected empty string, got %s", encrypted) + } + decrypted, err := Decrypt("") + if err != nil { + t.Fatalf("Failed to decrypt empty string: %v", err) + } + if decrypted != "" { + t.Fatalf("Expected empty string, got %s", decrypted) + } +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 6cf47a2..e235968 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "os/exec" + "time" "reflect" "strings" @@ -331,6 +332,24 @@ func CaptureWithEditor(filenamePattern, header, initialContent string) (string, return strings.TrimSpace(content.String()), nil } +// GetDateRange returns start and end time for common periods (daily, weekly, yearly) +func GetDateRange(period string) (time.Time, time.Time) { + now := time.Now() + switch period { + case "yearly", "year": + return time.Date(now.Year()-1, now.Month(), now.Day(), 0, 0, 0, 0, now.Location()), now + case "weekly", "week": + end := time.Now() + start := end.AddDate(0, 0, -7) + start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location()) + return start, end + case "daily", "day": + return now.Add(-24 * time.Hour), now + default: + return now.Add(-24 * time.Hour), now + } +} + // FormatMinutes converts float64 minutes into a human-readable string like "1hr 2min" func FormatMinutes(minutes float64) string { totalMinutes := int(math.Round(minutes)) diff --git a/models/types.go b/models/types.go index 5aba51a..7a2b956 100644 --- a/models/types.go +++ b/models/types.go @@ -461,3 +461,10 @@ type PointStats struct { TotalPoints int History []PointTransaction } + +// AppSetting represents a key-value store for application settings (like encrypted AI tokens) +type AppSetting struct { + gorm.Model + Key string `gorm:"type:varchar(100);uniqueIndex;not null" json:"key"` + Value string `gorm:"type:text" json:"value"` // encrypted if sensitive +} diff --git a/web/static/css/style.css b/web/static/css/style.css index fdf6507..c89be22 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -210,7 +210,7 @@ main.container { header { position: sticky; top: 1rem; - z-index: 50; + z-index: 200; margin: 0 auto 2rem auto; width: calc(100% - 2rem); max-width: 1200px; @@ -926,6 +926,11 @@ label { } } +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + .animate-fade-in { animation: fadeIn 0.5s ease-out forwards; } diff --git a/web/templates/journal.html b/web/templates/journal.html index 91f91e2..996e5b2 100644 --- a/web/templates/journal.html +++ b/web/templates/journal.html @@ -1,5 +1,5 @@ {{ define "content" }} - -