Compare commits
	
		
			10 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 1e463fec55 | |||
| 5a20e76df2 | |||
|   | 02c5c6ea68 | ||
|   | 9cbb8756d1 | ||
|   | 604b92b3b3 | ||
|   | c7236394d9 | ||
|   | 720ef83c2e | ||
|   | 5b1d107cac | ||
|   | 662257ebc5 | ||
|   | ad48882bca | 
							
								
								
									
										1
									
								
								backend/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,3 +1,4 @@ | ||||
| *.db3 | ||||
| *.db3-* | ||||
| *.db3.* | ||||
| /allowance_planner | ||||
|   | ||||
| @@ -15,8 +15,9 @@ const ( | ||||
| func startServer(t *testing.T) *httpexpect.Expect { | ||||
| 	config := ServerConfig{ | ||||
| 		Datasource: ":memory:", | ||||
| 		Addr:       ":0", | ||||
| 		Started:    make(chan bool), | ||||
| 		//Datasource: "test.db", | ||||
| 		Addr:    ":0", | ||||
| 		Started: make(chan bool), | ||||
| 	} | ||||
| 	go start(t.Context(), &config) | ||||
| 	<-config.Started | ||||
| @@ -284,6 +285,54 @@ func TestCreateTask(t *testing.T) { | ||||
| 	responseWithUser.Value("id").Number().IsEqual(2) | ||||
| } | ||||
|  | ||||
| func TestCreateScheduleTask(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
|  | ||||
| 	// Create a new task without assigned user | ||||
| 	requestBody := map[string]interface{}{ | ||||
| 		"name":     "Test Task", | ||||
| 		"reward":   100, | ||||
| 		"schedule": "0 */5 * * * *", | ||||
| 	} | ||||
|  | ||||
| 	response := e.POST("/tasks"). | ||||
| 		WithJSON(requestBody). | ||||
| 		Expect(). | ||||
| 		Status(201). // Expect Created status | ||||
| 		JSON().Object() | ||||
|  | ||||
| 	requestBody["schedule"] = "every 5 seconds" | ||||
| 	e.POST("/tasks").WithJSON(requestBody).Expect().Status(400) | ||||
|  | ||||
| 	// Verify the response has an ID | ||||
| 	response.ContainsKey("id") | ||||
| 	response.Value("id").Number().IsEqual(1) | ||||
|  | ||||
| 	e.GET("/tasks").Expect().Status(200).JSON().Array().Length().IsEqual(1) | ||||
|  | ||||
| 	// Get task | ||||
| 	result := e.GET("/task/1").Expect().Status(200).JSON().Object() | ||||
| 	result.Value("id").IsEqual(1) | ||||
| 	result.Value("name").IsEqual("Test Task") | ||||
| 	result.Value("schedule").IsEqual("0 */5 * * * *") | ||||
| 	result.Value("reward").IsEqual(100) | ||||
| 	result.Value("assigned").IsNull() | ||||
|  | ||||
| 	// Complete the task | ||||
| 	e.POST("/task/1/complete").Expect().Status(200) | ||||
|  | ||||
| 	// Set expires date to 1 second in the past | ||||
| 	db.db.Query("update tasks set next_run = ? where id = 1").Bind(time.Now().Add(10 * -time.Minute).Unix()).MustExec() | ||||
|  | ||||
| 	// Verify a new task is created | ||||
| 	newTask := e.GET("/task/2").Expect().Status(200).JSON().Object() | ||||
| 	newTask.Value("id").IsEqual(2) | ||||
| 	newTask.Value("name").IsEqual("Test Task") | ||||
| 	newTask.Value("schedule").IsEqual("0 */5 * * * *") | ||||
| 	newTask.Value("reward").IsEqual(100) | ||||
| 	newTask.Value("assigned").IsNull() | ||||
| } | ||||
|  | ||||
| func TestDeleteTask(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
|  | ||||
|   | ||||
							
								
								
									
										104
									
								
								backend/db.go
									
									
									
									
									
								
							
							
						
						| @@ -3,6 +3,7 @@ package main | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"github.com/adhocore/gronx" | ||||
| 	"log" | ||||
| 	"math" | ||||
| 	"time" | ||||
| @@ -313,10 +314,20 @@ func (db *Db) CreateTask(task *CreateTaskRequest) (int, error) { | ||||
| 	} | ||||
| 	defer tx.MustRollback() | ||||
|  | ||||
| 	var nextRun *int64 | ||||
| 	if task.Schedule != nil { | ||||
| 		nextRunTime, err := gronx.NextTick(*task.Schedule, false) | ||||
| 		if err != nil { | ||||
| 			return 0, fmt.Errorf("failed to calculate next run: %w", err) | ||||
| 		} | ||||
| 		nextRunTimeAsInt := nextRunTime.Unix() | ||||
| 		nextRun = &nextRunTimeAsInt | ||||
| 	} | ||||
|  | ||||
| 	// Insert the new task | ||||
| 	reward := int(math.Round(task.Reward * 100.0)) | ||||
| 	err = tx.Query("insert into tasks (name, reward, assigned) values (?, ?, ?)"). | ||||
| 		Bind(task.Name, reward, task.Assigned). | ||||
| 	err = tx.Query("insert into tasks (name, reward, assigned, schedule, next_run) values (?, ?, ?, ?, ?)"). | ||||
| 		Bind(task.Name, reward, task.Assigned, task.Schedule, nextRun). | ||||
| 		Exec() | ||||
|  | ||||
| 	if err != nil { | ||||
| @@ -340,13 +351,17 @@ func (db *Db) CreateTask(task *CreateTaskRequest) (int, error) { | ||||
| } | ||||
|  | ||||
| func (db *Db) GetTasks() ([]Task, error) { | ||||
| 	tasks := make([]Task, 0) | ||||
| 	var err error | ||||
| 	err := db.UpdateScheduledTasks() | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to update scheduled tasks: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	for row := range db.db.Query("select id, name, reward, assigned from tasks").Range(&err) { | ||||
| 	tasks := make([]Task, 0) | ||||
|  | ||||
| 	for row := range db.db.Query("select id, name, reward, assigned, schedule from tasks where completed is null").Range(&err) { | ||||
| 		task := Task{} | ||||
| 		var reward int64 | ||||
| 		err = row.Scan(&task.ID, &task.Name, &reward, &task.Assigned) | ||||
| 		err = row.Scan(&task.ID, &task.Name, &reward, &task.Assigned, &task.Schedule) | ||||
| 		task.Reward = float64(reward) / 100.0 | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| @@ -362,16 +377,78 @@ func (db *Db) GetTasks() ([]Task, error) { | ||||
| func (db *Db) GetTask(id int) (Task, error) { | ||||
| 	task := Task{} | ||||
|  | ||||
| 	var reward int64 | ||||
| 	err := db.db.Query("select id, name, reward, assigned from tasks where id = ?"). | ||||
| 		Bind(id).ScanSingle(&task.ID, &task.Name, &reward, &task.Assigned) | ||||
| 	task.Reward = float64(reward) / 100.0 | ||||
| 	err := db.UpdateScheduledTasks() | ||||
| 	if err != nil { | ||||
| 		return Task{}, err | ||||
| 		return Task{}, fmt.Errorf("failed to update scheduled tasks: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	var reward int64 | ||||
| 	err = db.db.Query("select id, name, reward, assigned, schedule from tasks where id = ? and completed is null"). | ||||
| 		Bind(id).ScanSingle(&task.ID, &task.Name, &reward, &task.Assigned, &task.Schedule) | ||||
| 	if err != nil { | ||||
| 		return task, err | ||||
| 	} | ||||
| 	task.Reward = float64(reward) / 100.0 | ||||
| 	return task, nil | ||||
| } | ||||
|  | ||||
| func (db *Db) UpdateScheduledTasks() error { | ||||
| 	type ScheduledTask struct { | ||||
| 		ID       int | ||||
| 		Schedule string | ||||
| 		Expires  int64 | ||||
| 	} | ||||
| 	tasks := make([]ScheduledTask, 0) | ||||
| 	var err error | ||||
|  | ||||
| 	for row := range db.db.Query("select id, schedule, next_run from tasks where schedule is not null").Range(&err) { | ||||
| 		task := ScheduledTask{} | ||||
| 		err := row.Scan(&task.ID, &task.Schedule, &task.Expires) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		if time.Now().Unix() >= task.Expires { | ||||
| 			tasks = append(tasks, task) | ||||
| 		} | ||||
| 	} | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to fetch scheduled tasks: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	tx, err := db.db.Begin() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer tx.MustRollback() | ||||
|  | ||||
| 	for _, task := range tasks { | ||||
| 		nextRun, err := gronx.NextTickAfter(task.Schedule, time.Now(), false) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("failed to calculate next run for task %d: %w", task.ID, err) | ||||
| 		} | ||||
|  | ||||
| 		err = tx.Query("insert into tasks (name, reward, assigned, schedule, next_run) select name, reward, assigned, schedule, ? from tasks where id = ?"). | ||||
| 			Bind(nextRun.Unix(), task.ID). | ||||
| 			Exec() | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
|  | ||||
| 		err = tx.Query("update tasks set schedule = null where id = ?").Bind(task.ID).Exec() | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
|  | ||||
| 		tx.Query("select last_insert_rowid()").MustScanSingle(&task.ID) | ||||
| 		log.Printf("Task %d scheduled for %s", task.ID, nextRun) | ||||
| 	} | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return tx.Commit() | ||||
| } | ||||
|  | ||||
| func (db *Db) DeleteTask(id int) error { | ||||
| 	tx, err := db.db.Begin() | ||||
| 	if err != nil { | ||||
| @@ -453,7 +530,10 @@ func (db *Db) CompleteTask(taskId int) error { | ||||
| 	} | ||||
|  | ||||
| 	// Remove the task | ||||
| 	err = tx.Query("delete from tasks where id = ?").Bind(taskId).Exec() | ||||
| 	err = tx.Query("update tasks set completed=? where id = ?").Bind(time.Now().Unix(), taskId).Exec() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return tx.Commit() | ||||
| } | ||||
|   | ||||
| @@ -29,7 +29,8 @@ type Task struct { | ||||
| 	ID       int     `json:"id"` | ||||
| 	Name     string  `json:"name"` | ||||
| 	Reward   float64 `json:"reward"` | ||||
| 	Assigned *int    `json:"assigned"` // Pointer to allow null | ||||
| 	Assigned *int    `json:"assigned"` | ||||
| 	Schedule *string `json:"schedule"` | ||||
| } | ||||
|  | ||||
| type Allowance struct { | ||||
| @@ -68,6 +69,7 @@ type CreateTaskRequest struct { | ||||
| 	Name     string  `json:"name" binding:"required"` | ||||
| 	Reward   float64 `json:"reward"` | ||||
| 	Assigned *int    `json:"assigned"` | ||||
| 	Schedule *string `json:"schedule"` | ||||
| } | ||||
|  | ||||
| type CreateTaskResponse struct { | ||||
|   | ||||
| @@ -6,11 +6,12 @@ require ( | ||||
| 	gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 | ||||
| 	github.com/gavv/httpexpect/v2 v2.17.0 | ||||
| 	github.com/gin-contrib/cors v1.7.5 | ||||
| 	github.com/gin-gonic/gin v1.10.0 | ||||
| 	github.com/gin-gonic/gin v1.10.1 | ||||
| ) | ||||
|  | ||||
| require ( | ||||
| 	github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect | ||||
| 	github.com/adhocore/gronx v1.19.6 // indirect | ||||
| 	github.com/ajg/form v1.5.1 // indirect | ||||
| 	github.com/andybalholm/brotli v1.1.1 // indirect | ||||
| 	github.com/bytedance/sonic v1.13.2 // indirect | ||||
| @@ -49,7 +50,7 @@ require ( | ||||
| 	github.com/sergi/go-diff v1.3.1 // indirect | ||||
| 	github.com/stretchr/testify v1.10.0 // indirect | ||||
| 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect | ||||
| 	github.com/ugorji/go/codec v1.2.12 // indirect | ||||
| 	github.com/ugorji/go/codec v1.2.14 // indirect | ||||
| 	github.com/valyala/bytebufferpool v1.0.0 // indirect | ||||
| 	github.com/valyala/fasthttp v1.62.0 // indirect | ||||
| 	github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect | ||||
| @@ -68,10 +69,10 @@ require ( | ||||
| 	gopkg.in/fsnotify.v1 v1.4.7 // indirect | ||||
| 	gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect | ||||
| 	gopkg.in/yaml.v3 v3.0.1 // indirect | ||||
| 	modernc.org/libc v1.65.7 // indirect | ||||
| 	modernc.org/libc v1.65.8 // indirect | ||||
| 	modernc.org/mathutil v1.7.1 // indirect | ||||
| 	modernc.org/memory v1.11.0 // indirect | ||||
| 	modernc.org/sqlite v1.37.0 // indirect | ||||
| 	modernc.org/sqlite v1.37.1 // indirect | ||||
| 	moul.io/http2curl/v2 v2.3.0 // indirect | ||||
| 	zombiezen.com/go/sqlite v1.4.0 // indirect | ||||
| 	zombiezen.com/go/sqlite v1.4.2 // indirect | ||||
| ) | ||||
|   | ||||
| @@ -2,6 +2,8 @@ gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 h1:aRItVfUj48fBmuec7rm/jY9KCfvHW | ||||
| gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE= | ||||
| github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4= | ||||
| github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w= | ||||
| github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc= | ||||
| github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= | ||||
| github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= | ||||
| github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= | ||||
| github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= | ||||
| @@ -34,6 +36,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w | ||||
| github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= | ||||
| github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= | ||||
| github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= | ||||
| github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= | ||||
| github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= | ||||
| github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= | ||||
| github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= | ||||
| github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= | ||||
| @@ -127,6 +131,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS | ||||
| github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= | ||||
| github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= | ||||
| github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= | ||||
| github.com/ugorji/go/codec v1.2.14 h1:yOQvXCBc3Ij46LRkRoh4Yd5qK6LVOgi0bYOXfb7ifjw= | ||||
| github.com/ugorji/go/codec v1.2.14/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= | ||||
| github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= | ||||
| github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= | ||||
| github.com/valyala/fasthttp v1.62.0 h1:8dKRBX/y2rCzyc6903Zu1+3qN0H/d2MsxPPmVNamiH0= | ||||
| @@ -216,6 +222,8 @@ modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= | ||||
| modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= | ||||
| modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= | ||||
| modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= | ||||
| modernc.org/libc v1.65.8 h1:7PXRJai0TXZ8uNA3srsmYzmTyrLoHImV5QxHeni108Q= | ||||
| modernc.org/libc v1.65.8/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= | ||||
| modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= | ||||
| modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= | ||||
| modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= | ||||
| @@ -226,6 +234,8 @@ modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= | ||||
| modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= | ||||
| modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= | ||||
| modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= | ||||
| modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= | ||||
| modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= | ||||
| modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= | ||||
| modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= | ||||
| modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= | ||||
| @@ -235,3 +245,5 @@ moul.io/http2curl/v2 v2.3.0/go.mod h1:RW4hyBjTWSYDOxapodpNEtX0g5Eb16sxklBqmd2RHc | ||||
| nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= | ||||
| zombiezen.com/go/sqlite v1.4.0 h1:N1s3RIljwtp4541Y8rM880qgGIgq3fTD2yks1xftnKU= | ||||
| zombiezen.com/go/sqlite v1.4.0/go.mod h1:0w9F1DN9IZj9AcLS9YDKMboubCACkwYCGkzoy3eG5ik= | ||||
| zombiezen.com/go/sqlite v1.4.2 h1:KZXLrBuJ7tKNEm+VJcApLMeQbhmAUOKA5VWS93DfFRo= | ||||
| zombiezen.com/go/sqlite v1.4.2/go.mod h1:5Kd4taTAD4MkBzT25mQ9uaAlLjyR0rFhsR6iINO70jc= | ||||
|   | ||||
| @@ -4,7 +4,9 @@ import ( | ||||
| 	"context" | ||||
| 	"embed" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"gitea.seeseepuff.be/seeseemelk/mysqlite" | ||||
| 	"github.com/adhocore/gronx" | ||||
| 	"log" | ||||
| 	"net" | ||||
| 	"net/http" | ||||
| @@ -43,6 +45,11 @@ type ServerConfig struct { | ||||
| 	Started chan bool | ||||
| } | ||||
|  | ||||
| const DefaultDomain = "localhost:8080" | ||||
|  | ||||
| // The domain that the server is reachable at. | ||||
| var domain = DefaultDomain | ||||
|  | ||||
| func getUsers(c *gin.Context) { | ||||
| 	users, err := db.GetUsers() | ||||
| 	if err != nil { | ||||
| @@ -431,6 +438,14 @@ func createTask(c *gin.Context) { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if taskRequest.Schedule != nil { | ||||
| 		valid := gronx.IsValid(*taskRequest.Schedule) | ||||
| 		if !valid { | ||||
| 			c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid cron schedule: %s", *taskRequest.Schedule)}) | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// If assigned is not nil, check if user exists | ||||
| 	if taskRequest.Assigned != nil { | ||||
| 		exists, err := db.UserExists(*taskRequest.Assigned) | ||||
| @@ -508,6 +523,11 @@ func putTask(c *gin.Context) { | ||||
| 		c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"}) | ||||
| 		return | ||||
| 	} | ||||
| 	if err != nil { | ||||
| 		log.Printf("Error getting task: %v", err) | ||||
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError}) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	err = db.UpdateTask(taskId, &taskRequest) | ||||
| 	if err != nil { | ||||
| @@ -706,5 +726,10 @@ func main() { | ||||
| 		config.Datasource = "allowance_planner.db3" | ||||
| 		log.Printf("Warning: No DB_PATH set, using default of %s", config.Datasource) | ||||
| 	} | ||||
| 	domain = os.Getenv("DOMAIN") | ||||
| 	if domain == "" { | ||||
| 		domain = DefaultDomain | ||||
| 		log.Printf("Warning: No DOMAIN set, using default of %s", domain) | ||||
| 	} | ||||
| 	start(context.Background(), &config) | ||||
| } | ||||
|   | ||||
							
								
								
									
										3
									
								
								backend/migrations/5_add_schedules.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,3 @@ | ||||
| alter table tasks add column schedule text; | ||||
| alter table tasks add column completed date; | ||||
| alter table tasks add column next_run date; | ||||
| @@ -3,6 +3,7 @@ package main | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"log" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| ) | ||||
| @@ -26,11 +27,22 @@ func loadWebEndpoints(router *gin.Engine) { | ||||
| 	router.GET("/completeAllowance", renderCompleteAllowance) | ||||
| } | ||||
|  | ||||
| func redirectToPage(c *gin.Context, page string) { | ||||
| 	redirectToPageStatus(c, page, http.StatusSeeOther) | ||||
| } | ||||
|  | ||||
| func redirectToPageStatus(c *gin.Context, page string, status int) { | ||||
| 	scheme := c.Request.URL.Scheme | ||||
| 	target := scheme + domain + page | ||||
| 	c.Redirect(status, target) | ||||
| } | ||||
|  | ||||
| func renderLogin(c *gin.Context) { | ||||
| 	if c.Query("user") != "" { | ||||
| 		c.SetCookie("user", c.Query("user"), 3600, "/", "localhost", false, true) | ||||
| 		log.Println("Set cookie for user:", c.Query("user")) | ||||
| 		c.SetCookie("user", c.Query("user"), 3600, "", "", false, true) | ||||
| 	} | ||||
| 	c.Redirect(http.StatusFound, "/") | ||||
| 	redirectToPage(c, "/") | ||||
| } | ||||
|  | ||||
| func renderIndex(c *gin.Context) { | ||||
| @@ -59,16 +71,24 @@ func renderCreateTask(c *gin.Context) { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	_, err = db.CreateTask(&CreateTaskRequest{ | ||||
| 	request := &CreateTaskRequest{ | ||||
| 		Name:   name, | ||||
| 		Reward: reward, | ||||
| 	}) | ||||
| 	} | ||||
|  | ||||
| 	schedule := c.PostForm("schedule") | ||||
| 	if schedule != "" { | ||||
| 		request.Schedule = &schedule | ||||
| 	} | ||||
| 	 | ||||
| 	_, err = db.CreateTask(request) | ||||
|  | ||||
| 	if err != nil { | ||||
| 		renderError(c, http.StatusInternalServerError, err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.Redirect(http.StatusFound, "/") | ||||
| 	redirectToPageStatus(c, "/", http.StatusFound) | ||||
| } | ||||
|  | ||||
| func renderCompleteTask(c *gin.Context) { | ||||
| @@ -85,7 +105,7 @@ func renderCompleteTask(c *gin.Context) { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.Redirect(http.StatusFound, "/") | ||||
| 	redirectToPageStatus(c, "/", http.StatusFound) | ||||
| } | ||||
|  | ||||
| func renderCreateAllowance(c *gin.Context) { | ||||
| @@ -122,7 +142,7 @@ func renderCreateAllowance(c *gin.Context) { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.Redirect(http.StatusFound, "/") | ||||
| 	redirectToPageStatus(c, "/", http.StatusFound) | ||||
| } | ||||
|  | ||||
| func renderCompleteAllowance(c *gin.Context) { | ||||
| @@ -144,11 +164,12 @@ func renderCompleteAllowance(c *gin.Context) { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.Redirect(http.StatusFound, "/") | ||||
| 	redirectToPageStatus(c, "/", http.StatusFound) | ||||
| } | ||||
|  | ||||
| func getCurrentUser(c *gin.Context) *int { | ||||
| 	currentUserStr, err := c.Cookie("user") | ||||
| 	log.Println("Cookie string:", currentUserStr) | ||||
| 	if errors.Is(err, http.ErrNoCookie) { | ||||
| 		renderNoUser(c) | ||||
| 		return nil | ||||
| @@ -172,7 +193,7 @@ func getCurrentUser(c *gin.Context) *int { | ||||
|  | ||||
| func unsetUserCookie(c *gin.Context) { | ||||
| 	c.SetCookie("user", "", -1, "/", "localhost", false, true) | ||||
| 	c.Redirect(http.StatusFound, "/") | ||||
| 	redirectToPageStatus(c, "/", http.StatusFound) | ||||
| } | ||||
|  | ||||
| func renderNoUser(c *gin.Context) { | ||||
|   | ||||
| @@ -3,9 +3,11 @@ | ||||
| <head> | ||||
| 	<title>Allowance Planner 2000</title> | ||||
| 	<style> | ||||
| 		<!-- | ||||
| 		tr:hover { | ||||
| 			background-color: #f0f0f0; | ||||
| 		} | ||||
| 		--> | ||||
| 	</style> | ||||
| </head> | ||||
| <body> | ||||
| @@ -27,7 +29,7 @@ | ||||
| 	{{if ne .CurrentUser 0}} | ||||
| 		<h2>Allowances</h2> | ||||
| 		<form action="/createAllowance" method="post"> | ||||
| 			<table border="1"> | ||||
| 			<table border=1> | ||||
| 				<thead> | ||||
| 				<tr> | ||||
| 					<th>Name</th> | ||||
| @@ -43,7 +45,7 @@ | ||||
| 						<td></td> | ||||
| 						<td><label><input type="number" name="target" placeholder="Target"></label></td> | ||||
| 						<td><label><input type="number" name="weight" placeholder="Weight"></label></td> | ||||
| 						<td><button>Create</button></td> | ||||
| 						<td><input type="submit" value="Create"></td> | ||||
| 					</tr> | ||||
| 				{{range .Allowances}} | ||||
| 					{{if eq .ID 0}} | ||||
| @@ -79,6 +81,7 @@ | ||||
| 					<th>Name</th> | ||||
| 					<th>Assigned</th> | ||||
| 					<th>Reward</th> | ||||
| 					<th>Schedule</th> | ||||
| 					<th>Actions</th> | ||||
| 				</tr> | ||||
| 				</thead> | ||||
| @@ -94,6 +97,7 @@ | ||||
| 							{{end}} | ||||
| 						</td> | ||||
| 						<td>{{.Reward}}</td> | ||||
| 						<td>{{.Schedule}}</td> | ||||
| 						<td> | ||||
| 							<a href="/completeTask?task={{.ID}}">Mark as completed</a> | ||||
| 						</td> | ||||
| @@ -103,7 +107,8 @@ | ||||
| 							<td><label><input type="text" name="name" placeholder="Name"></label></td> | ||||
| 							<td></td> | ||||
| 							<td><label><input type="number" name="reward" placeholder="Reward"></label></td> | ||||
| 							<td><button>Create</button></td> | ||||
| 							<td><label><input type="text" name="schedule" placeholder="Schedule"></label></td> | ||||
| 							<td><input type="submit" value="Create"></td> | ||||
| 						</tr> | ||||
| 				</tbody> | ||||
| 			</table> | ||||
|   | ||||
| @@ -422,7 +422,10 @@ components: | ||||
|           description: The task name | ||||
|         reward: | ||||
|           type: integer | ||||
|           description: The task reward, in cents | ||||
|           description: The task reward | ||||
|         schedule: | ||||
|           type: string | ||||
|           description: The schedule of the task, in cron format | ||||
|         assigned: | ||||
|           type: integer | ||||
|           description: The user ID of the user assigned to the task | ||||
|   | ||||
| Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 33 KiB | 
| After Width: | Height: | Size: 9.1 KiB | 
| Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 16 KiB | 
| Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 64 KiB | 
| Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 91 KiB | 
| Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 122 KiB | 
| Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 32 KiB | 
| After Width: | Height: | Size: 9.1 KiB | 
| Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 16 KiB | 
| Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 65 KiB | 
| Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 90 KiB | 
| Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 119 KiB | 
| Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 16 KiB | 
| @@ -1,5 +1,9 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> | ||||
|     <background android:drawable="@color/ic_launcher_background"/> | ||||
|     <foreground android:drawable="@mipmap/ic_launcher_foreground"/> | ||||
|     <background> | ||||
|         <inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" /> | ||||
|     </background> | ||||
|     <foreground> | ||||
|         <inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" /> | ||||
|     </foreground> | ||||
| </adaptive-icon> | ||||
| @@ -1,5 +1,9 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> | ||||
|     <background android:drawable="@color/ic_launcher_background"/> | ||||
|     <foreground android:drawable="@mipmap/ic_launcher_foreground"/> | ||||
|     <background> | ||||
|         <inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" /> | ||||
|     </background> | ||||
|     <foreground> | ||||
|         <inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" /> | ||||
|     </foreground> | ||||
| </adaptive-icon> | ||||
| After Width: | Height: | Size: 660 B | 
| Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 5.1 KiB | 
| After Width: | Height: | Size: 296 B | 
| After Width: | Height: | Size: 2.1 KiB | 
| After Width: | Height: | Size: 408 B | 
| Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.0 KiB | 
| After Width: | Height: | Size: 1006 B | 
| Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 7.5 KiB | 
| After Width: | Height: | Size: 1.8 KiB | 
| Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 13 KiB | 
| After Width: | Height: | Size: 2.5 KiB | 
| Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 18 KiB | 
| @@ -1,7 +1,7 @@ | ||||
| <?xml version='1.0' encoding='utf-8'?> | ||||
| <resources> | ||||
|     <string name="app_name">allowance-planner-v2</string> | ||||
|     <string name="title_activity_main">allowance-planner-v2</string> | ||||
|     <string name="app_name">Allowance Planner V2</string> | ||||
|     <string name="title_activity_main">Allowance Planner V2</string> | ||||
|     <string name="package_name">io.ionic.starter</string> | ||||
|     <string name="custom_url_scheme">io.ionic.starter</string> | ||||
| </resources> | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								frontend/allowance-planner-v2/assets/icon-background.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 38 KiB | 
							
								
								
									
										
											BIN
										
									
								
								frontend/allowance-planner-v2/assets/icon-foreground.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 163 KiB | 
							
								
								
									
										
											BIN
										
									
								
								frontend/allowance-planner-v2/assets/splash.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 130 KiB | 
| @@ -2,7 +2,7 @@ import type { CapacitorConfig } from '@capacitor/cli'; | ||||
|  | ||||
| const config: CapacitorConfig = { | ||||
|   appId: 'io.ionic.starter', | ||||
|   appName: 'allowance-planner-v2', | ||||
|   appName: 'Allowance Planner V2', | ||||
|   webDir: 'www' | ||||
| }; | ||||
|  | ||||
|   | ||||
							
								
								
									
										3471
									
								
								frontend/allowance-planner-v2/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						| @@ -47,6 +47,7 @@ | ||||
|     "@angular/cli": "^19.0.0", | ||||
|     "@angular/compiler-cli": "^19.0.0", | ||||
|     "@angular/language-service": "^19.0.0", | ||||
|     "@capacitor/assets": "^3.0.5", | ||||
|     "@capacitor/cli": "7.2.0", | ||||
|     "@ionic/angular-toolkit": "^12.0.0", | ||||
|     "@types/jasmine": "~5.1.0", | ||||
|   | ||||
| @@ -18,6 +18,16 @@ form, | ||||
|     align-items: center; | ||||
| } | ||||
|  | ||||
| input { | ||||
|     border: 1px solid var(--ion-color-primary); | ||||
|     border-radius: 5px; | ||||
|     width: 250px; | ||||
|     height: 40px; | ||||
|     padding-inline: 10px; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
| } | ||||
|  | ||||
| label { | ||||
|     color: var(--ion-color-primary); | ||||
|     margin-top: 25px; | ||||
| @@ -30,8 +40,7 @@ button { | ||||
|     color: white; | ||||
|     padding: 10px; | ||||
|     width: 250px; | ||||
|     margin-top: auto; | ||||
|     margin-bottom: 50px; | ||||
|     margin-top: 100px; | ||||
| } | ||||
|  | ||||
| button:disabled, | ||||
|   | ||||
| @@ -36,7 +36,7 @@ export class AllowancePage implements ViewWillEnter { | ||||
|         allowance[0].name = 'Main Allowance'; | ||||
|         this.allowance$.next(allowance); | ||||
|       }) | ||||
|     }, 50); | ||||
|     }, 100); | ||||
|   } | ||||
|  | ||||
|   canFinishGoal(allowance: Allowance): boolean { | ||||
|   | ||||
| @@ -8,6 +8,7 @@ import { EditAllowancePageRoutingModule } from './edit-allowance-routing.module' | ||||
|  | ||||
| import { EditAllowancePage } from './edit-allowance.page'; | ||||
| import { MatIconModule } from '@angular/material/icon'; | ||||
| import { MatSelectModule } from '@angular/material/select'; | ||||
|  | ||||
| @NgModule({ | ||||
|   imports: [ | ||||
| @@ -16,7 +17,8 @@ import { MatIconModule } from '@angular/material/icon'; | ||||
|     IonicModule, | ||||
|     EditAllowancePageRoutingModule, | ||||
|     ReactiveFormsModule, | ||||
|     MatIconModule | ||||
|     MatIconModule, | ||||
|     MatSelectModule | ||||
|   ], | ||||
|   declarations: [EditAllowancePage] | ||||
| }) | ||||
|   | ||||
| @@ -7,11 +7,6 @@ | ||||
|       <ion-title *ngIf="isAddMode">Create Goal</ion-title> | ||||
|       <ion-title *ngIf="!isAddMode && goalId != 0">Edit Goal</ion-title> | ||||
|       <ion-title *ngIf="!isAddMode && goalId == 0">Edit Allowance</ion-title> | ||||
|       <button | ||||
|         *ngIf="!isAddMode && goalId !=0" | ||||
|         class="remove-button" | ||||
|         (click)="deleteAllowance()" | ||||
|       >Delete Goal</button> | ||||
|     </div> | ||||
|   </ion-toolbar> | ||||
| </ion-header> | ||||
| @@ -33,9 +28,9 @@ | ||||
|  | ||||
|     <div class="item" *ngIf="isAddMode || goalId != 0"> | ||||
|       <label>Colour</label> | ||||
|       <select formControlName="color"> | ||||
|         <option *ngFor="let color of possibleColors" [value]="color" [style.--background]="color">{{color}}</option> | ||||
|       </select> | ||||
|       <mat-select [(value)]="selectedColor" formControlName="color" [style.--color]="selectedColor"> | ||||
|         <mat-option *ngFor="let color of possibleColors" [value]="color" [style.--background]="color">{{color}}</mat-option> | ||||
|       </mat-select> | ||||
|     </div> | ||||
|  | ||||
|     <button type="button" [disabled]="!form.valid" (click)="submit()"> | ||||
| @@ -43,5 +38,10 @@ | ||||
|       <span *ngIf="!isAddMode && goalId != 0">Update Goal</span> | ||||
|       <span *ngIf="!isAddMode && goalId == 0">Update Allowance</span> | ||||
|     </button> | ||||
|     <button | ||||
|       *ngIf="!isAddMode && goalId !=0" | ||||
|       class="remove-button" | ||||
|       (click)="deleteAllowance()" | ||||
|     >Delete Goal</button> | ||||
|   </form> | ||||
| </ion-content> | ||||
|   | ||||
| @@ -4,10 +4,8 @@ | ||||
| } | ||||
|  | ||||
| .remove-button { | ||||
|     background-color: var(--ion-color-primary); | ||||
|     margin-right: 15px; | ||||
|     width: 100px; | ||||
|     margin-bottom: 0; | ||||
|     margin-top: 10px; | ||||
|     background-color: var(--negative-amount-color); | ||||
| } | ||||
|  | ||||
| form { | ||||
| @@ -28,17 +26,23 @@ label { | ||||
| } | ||||
|  | ||||
| input, | ||||
| select { | ||||
| mat-select { | ||||
|     --color: black; | ||||
|     color: var(--color); | ||||
|     border: 1px solid var(--ion-color-primary); | ||||
|     border-radius: 5px; | ||||
|     width: 250px; | ||||
|     height: 40px; | ||||
|     padding-inline: 10px; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     font-family: (--ion-font-family); | ||||
| } | ||||
|  | ||||
| option { | ||||
| mat-option { | ||||
|     --background: white; | ||||
|     background-color: var(--background); | ||||
|     color: var(--background); | ||||
|     font-family: var(--ion-font-family); | ||||
|     font-family: (--ion-font-family); | ||||
| } | ||||
|  | ||||
| button { | ||||
| @@ -47,8 +51,7 @@ button { | ||||
|     color: white; | ||||
|     padding: 10px; | ||||
|     width: 250px; | ||||
|     margin-top: auto; | ||||
|     margin-bottom: 50px; | ||||
|     margin-top: 100px; | ||||
| } | ||||
|  | ||||
| button:disabled, | ||||
|   | ||||
| @@ -15,6 +15,7 @@ export class EditAllowancePage implements OnInit { | ||||
|   public goalId: number; | ||||
|   public userId: number; | ||||
|   public isAddMode: boolean; | ||||
|   public selectedColor: string = ''; | ||||
|   public possibleColors: Array<string> = [ | ||||
|     '#6199D9', | ||||
|     '#D98B61', | ||||
| @@ -73,6 +74,7 @@ export class EditAllowancePage implements OnInit { | ||||
|             weight: allowance.weight, | ||||
|             color: allowance.colour | ||||
|           }); | ||||
|           this.selectedColor = this.form.value.color; | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|   | ||||
| @@ -8,6 +8,7 @@ import { EditTaskPageRoutingModule } from './edit-task-routing.module'; | ||||
|  | ||||
| import { EditTaskPage } from './edit-task.page'; | ||||
| import { MatIconModule } from '@angular/material/icon'; | ||||
| import { MatSelectModule } from '@angular/material/select'; | ||||
|  | ||||
| @NgModule({ | ||||
|   imports: [ | ||||
| @@ -16,7 +17,8 @@ import { MatIconModule } from '@angular/material/icon'; | ||||
|     IonicModule, | ||||
|     EditTaskPageRoutingModule, | ||||
|     ReactiveFormsModule, | ||||
|     MatIconModule | ||||
|     MatIconModule, | ||||
|     MatSelectModule | ||||
|   ], | ||||
|   declarations: [EditTaskPage] | ||||
| }) | ||||
|   | ||||
| @@ -6,11 +6,6 @@ | ||||
|       </div> | ||||
|       <ion-title *ngIf="isAddMode">Create Task</ion-title> | ||||
|       <ion-title *ngIf="!isAddMode">Edit Task</ion-title> | ||||
|       <button | ||||
|         *ngIf="!isAddMode" | ||||
|         class="remove-button" | ||||
|         (click)="deleteTask()" | ||||
|       >Delete task</button> | ||||
|     </div> | ||||
|   </ion-toolbar> | ||||
| </ion-header> | ||||
| @@ -24,13 +19,18 @@ | ||||
|     <input id="reward" type="number" placeholder="0.00" name="price" min="0" value="0" step="0.01" formControlName="reward"/> | ||||
|  | ||||
|     <label>Assigned</label> | ||||
|     <select formControlName="assigned"> | ||||
|       <option *ngFor="let user of users" [value]="user.id">{{ user.name }}</option> | ||||
|     </select> | ||||
|     <mat-select formControlName="assigned"> | ||||
|       <mat-option *ngFor="let user of users" [value]="user.id">{{ user.name }}</mat-option> | ||||
|     </mat-select> | ||||
|  | ||||
|     <button type="button" [disabled]="!form.valid" (click)="submit()"> | ||||
|       <span *ngIf="isAddMode">Add Task</span> | ||||
|       <span *ngIf="!isAddMode">Update Task</span> | ||||
|     </button> | ||||
|     <button | ||||
|       *ngIf="!isAddMode" | ||||
|       class="remove-button" | ||||
|       (click)="deleteTask()" | ||||
|     >Delete task</button> | ||||
|   </form> | ||||
| </ion-content> | ||||
|   | ||||
| @@ -4,10 +4,8 @@ | ||||
| } | ||||
|  | ||||
| .remove-button { | ||||
|     background-color: var(--ion-color-primary); | ||||
|     margin-right: 15px; | ||||
|     width: 95px; | ||||
|     margin-bottom: 0; | ||||
|     margin-top: 10px; | ||||
|     background-color: var(--negative-amount-color); | ||||
| } | ||||
|  | ||||
| form { | ||||
| @@ -24,10 +22,15 @@ label { | ||||
| } | ||||
|  | ||||
| input, | ||||
| select { | ||||
| mat-select { | ||||
|     border: 1px solid var(--ion-color-primary); | ||||
|     border-radius: 5px; | ||||
|     width: 250px; | ||||
|     height: 40px; | ||||
|     padding-inline: 10px; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     font-family: (--ion-font-family); | ||||
| } | ||||
|  | ||||
| button { | ||||
| @@ -36,8 +39,7 @@ button { | ||||
|     color: white; | ||||
|     padding: 10px; | ||||
|     width: 250px; | ||||
|     margin-top: auto; | ||||
|     margin-bottom: 50px; | ||||
|     margin-top: 100px; | ||||
| } | ||||
|  | ||||
| button:disabled, | ||||
|   | ||||
| @@ -8,6 +8,7 @@ | ||||
|  | ||||
| .left { | ||||
|     width: 70%; | ||||
|     font-size: 18px; | ||||
| } | ||||
|  | ||||
| .date { | ||||
|   | ||||
| @@ -18,8 +18,12 @@ | ||||
|       <div class="task" *ngFor="let task of tasks$ | async"> | ||||
|         <button (click)="completeTask(task.id)">Done</button> | ||||
|         <div (click)="updateTask(task.id)" class="item"> | ||||
|           <div class="name">{{ task.name }}</div> | ||||
|           <div class="assigned">{{ usernames[task.assigned ? task.assigned : 0] }}</div> | ||||
|           <div class="text"> | ||||
|             <div class="name"> | ||||
|               {{ task.name }} | ||||
|               <span class="assigned">{{ usernames[task.assigned ? task.assigned : 0] }}</span> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div | ||||
|             class="reward" | ||||
|             [ngClass]="{ 'negative': task.reward < 0 }" | ||||
|   | ||||
| @@ -31,6 +31,8 @@ mat-icon { | ||||
|     align-items: center; | ||||
|     border-bottom: 1px solid var(--line-color); | ||||
|     padding: 5px; | ||||
|     padding-block: 10px; | ||||
|     font-size: 18px; | ||||
| } | ||||
|  | ||||
| .item { | ||||
| @@ -41,7 +43,6 @@ mat-icon { | ||||
| } | ||||
|  | ||||
| .name { | ||||
|     margin-left: 10px; | ||||
|     color: var(--font-color); | ||||
| } | ||||
|  | ||||
| @@ -49,6 +50,7 @@ mat-icon { | ||||
|     margin-left: auto; | ||||
|     margin-right: 15px; | ||||
|     color: var(--positive-amount-color); | ||||
|     font-size: 22px; | ||||
| } | ||||
|  | ||||
| .negative { | ||||
| @@ -56,21 +58,28 @@ mat-icon { | ||||
| } | ||||
|  | ||||
| button { | ||||
|     width: 57px; | ||||
|     height: 30px; | ||||
|     height: 45px; | ||||
|     border-radius: 10px; | ||||
|     color: white; | ||||
|     background: var(--confirm-button-color); | ||||
|     padding-inline: 15px; | ||||
| } | ||||
|  | ||||
| .add-button { | ||||
|     background-color: var(--ion-color-primary); | ||||
|     margin-right: 15px; | ||||
|     width: 75px; | ||||
|     height: 30px; | ||||
| } | ||||
|  | ||||
| .assigned { | ||||
|     color: var(--line-color); | ||||
|     margin-left: 3px; | ||||
|     font-size: 12px; | ||||
|     font-size: 15px; | ||||
| } | ||||
|  | ||||
| .text { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     width: 60%; | ||||
|     margin-left: 10px; | ||||
| } | ||||
| @@ -33,7 +33,7 @@ export class TasksPage implements ViewWillEnter { | ||||
|       this.taskService.getTaskList().subscribe(tasks => { | ||||
|         this.tasks$.next(tasks); | ||||
|       }); | ||||
|     }, 50); | ||||
|     }, 100); | ||||
|   } | ||||
|  | ||||
|   createTask() { | ||||
|   | ||||
| @@ -38,6 +38,7 @@ | ||||
|  | ||||
| ion-title { | ||||
|     color: var(--ion-color-primary); | ||||
|     font-size: 24px; | ||||
| } | ||||
|  | ||||
| ion-header { | ||||
| @@ -47,3 +48,24 @@ ion-header { | ||||
| button { | ||||
|     font-size: 16px; | ||||
| } | ||||
|  | ||||
| ion-header.md { | ||||
|     ion-toolbar:first-child { | ||||
|         --padding-top: 30px; | ||||
|         --padding-bottom: 15px; | ||||
|     } | ||||
| } | ||||
|  | ||||
| label { | ||||
|     font-size: 18px; | ||||
| } | ||||
|  | ||||
| ion-alert .alert-wrapper.sc-ion-alert-md { | ||||
|     background-color: var(--ion-background-color) !important; | ||||
|     --background: unset !important; | ||||
|     box-shadow: unset; | ||||
| } | ||||
|  | ||||
| ion-alert .alert-tappable.sc-ion-alert-md { | ||||
|     background-color: var(--test-color); | ||||
| } | ||||