diff --git a/.gitignore b/.gitignore
index e69de29..4a7914f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -0,0 +1,37 @@
+# macOS
+.DS_Store
+
+# Environment files
+.env
+.env.*
+frontend/.env
+backend/.env
+
+# Logs
+*.log
+logs/
+
+# Node / Vite / Svelte
+frontend/node_modules/
+frontend/dist/
+frontend/.vite/
+frontend/.svelte-kit/
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# Go build artifacts
+backend/bin/
+backend/tmp/
+backend/coverage/
+backend/*.out
+
+# Local database files (keep committed sample db.json; ignore local variants)
+backend/db.local.json
+backend/data/*.json
+
+# IDE caches (keep tracked settings if any)
+.idea/
+.pytest_cache/
+.cache/
\ No newline at end of file
diff --git a/README.md b/README.md
index a66f4cc..e887cf4 100644
--- a/README.md
+++ b/README.md
@@ -1,47 +1,104 @@
-Create the foundation of a to-do list application, focusing on backend functionality and essential frontend interaction. Your task is implementing a RESTful API using Go and a simple TypeScript interface using [Svelte](https://svelte.dev/). The goal is to be able to be able to list and add todos.
+# Kanban Taskboard — Go + Svelte
-You are not expected to know Go, Svelte, or OpenAPI. Part of the challenge is to see how quickly you can adapt and pick new things up!
+This project is a Kanban-style task board with a Go backend and a Svelte frontend. Tasks are persisted to a local JSON file and exposed via REST endpoints. The UI supports columns (Kanban statuses), drag-and-drop, inline editing, and a calendar view for deadlines.
-The backend needs to meet the openapi spec which is within the backend folder. You need to create the list endpoint and the add todo endpoint. The storage system is in-memory.
+## Overview
-The frontend already has functionality to list the todos, your task is to complete the form which submits todo's to the backend system.
+- Backend (Go): JSON file–backed store with REST endpoints for `kanban` (columns) and `tasks`.
+- Frontend (Svelte): Loads columns and tasks from the backend, persists all edits (add/update/move/delete), and caches to `localStorage` as a fallback.
+- Data models:
+ - `kanban`: `{ id, title }[]`
+ - `tasks`: `{ id, name, description, priority, deadline, status_id, position }[]`
-Please use this repo as your base. You can click "Use this template" in the top right on GitHub, then "Create a new repository". Commit and push your code so it can reviewed by us. Please get as far as you can within 2 hours.
+## Setup
-If you find anything in the template you would like to improve, please feel free to!
+Install:
+- Go ([install instructions](https://go.dev/doc/install))
+- Node.js + npm (we recommend [NVM](https://github.com/nvm-sh/nvm))
+Tested with Go 1.25 and Node 20.
-## Setup
+## Running
-If not already installed, please install the following:
-1. Go ([install instructions](https://go.dev/doc/install))
-2. NPM/NodeJS. We recommend using [NVM](https://github.com/nvm-sh/nvm)
+Open two terminals: one for the backend, one for the frontend.
-We have tested this with Go 1.25 and Node 20. You may have issues if you try to use a different version.
+### Backend (Go)
-A good starting point would be to look at the following files:
-- `backend/main.go`
-- `frontend/src/App.svelte`
+1. `cd backend`
+2. `go run .`
-## Running
+Environment:
+- `PORT` (optional): default `8080`
+- `DB_FILE` (optional): path to JSON file, default `db.json`
+
+On first run, if `DB_FILE` is missing, sample columns are created and the file is saved.
+
+### Frontend (Svelte)
+
+1. `cd frontend`
+2. `npm install`
+3. `npm run dev`
+4. Open `http://localhost:5174/`
+
+Configuration:
+- `VITE_API_BASE_URL` (optional): API base, default `http://localhost:8080`
+
+## REST API
+
+Base URL: `http://localhost:8080`
+
+### Columns (Kanban)
+
+- `GET /api/kanban` → `[{ id, title }]`
+- `POST /api/kanban` body: `{ title }` → created column
+- `PATCH /api/kanban/{id}` body: `{ title }` → updated column
+- `DELETE /api/kanban/{id}` → deletes column and cascades tasks in that column
+
+### Tasks
+
+- `GET /api/tasks` → `[{ id, name, description, priority, deadline, status_id, position }]`
+- `POST /api/tasks` body: `{ name, description, priority, deadline, status_id }` → created task
+- `PATCH /api/tasks/{id}` body: any subset of fields (partial update)
+- `DELETE /api/tasks/{id}` → deletes task
+
+Notes:
+- Task positions are maintained per column; new tasks are appended to the end.
+- Partial updates do not overwrite omitted fields.
+
+## Frontend Behavior
-Open two separate terminals - one for the Svelte app and one for the golang API.
+- Kanban board: add/rename/delete columns; add/edit/move/delete tasks; drag to reorder.
+- Calendar view: tasks appear on their `deadline` date; dragging a task to a new date updates only `deadline`.
+- All changes persist to the backend and cache to `localStorage`.
+## Quick Test (curl)
-### Golang API
+```bash
+# List columns
+curl http://localhost:8080/api/kanban
-1. In the first terminal, change to the backend directory (`cd backend`)
-2. Run `go run main.go` to start the API server
+# Create a column
+curl -X POST http://localhost:8080/api/kanban \
+ -H 'Content-Type: application/json' \
+ -d '{"title":"Backlog"}'
-This must be running for the frontend to work.
+# Create a task in column id "todo"
+curl -X POST http://localhost:8080/api/tasks \
+ -H 'Content-Type: application/json' \
+ -d '{"name":"Write report","description":"Q4 summary","priority":"Medium","deadline":"2025-11-20","status_id":"todo"}'
-When you make a change, you must stop the server (`ctrl-c` in the terminal), and restart it with `go run main.go`.
+# Move task and change deadline (partial update)
+curl -X PATCH http://localhost:8080/api/tasks/{task_id} \
+ -H 'Content-Type: application/json' \
+ -d '{"status_id":"inprogress","deadline":"2025-11-22"}'
+```
+## Files of Interest
-### Svelte App
+- Backend: `backend/main.go` (HTTP handlers, JSON DB), `backend/db.json` (data file)
+- Frontend: `frontend/src/lib/components/Kanban.svelte`, `frontend/src/lib/components/CalendarView.svelte`, `frontend/src/lib/stores/taskboard.js`
-1. In the second terminal, change to the frontend directory (`cd frontend`)
-2. Run `npm run dev` to start the Svelte app
-3. If it doesn't open automatically, open [http://localhost:5173](http://localhost:5173) to view your website
+## Troubleshooting
-Leave this running. It will automatically update when you make any changes.
+- Ensure the backend is running on `:8080` or set `VITE_API_BASE_URL` for the frontend.
+- If task names disappear when changing the deadline, make sure you are running the patched backend that performs partial updates correctly.
\ No newline at end of file
diff --git a/backend/db.json b/backend/db.json
new file mode 100644
index 0000000..24abdbf
--- /dev/null
+++ b/backend/db.json
@@ -0,0 +1,36 @@
+{
+ "kanban": [
+ {
+ "id": "todo",
+ "title": "To Do"
+ },
+ {
+ "id": "inprogress",
+ "title": "In Progresswef"
+ },
+ {
+ "id": "done",
+ "title": "Done"
+ }
+ ],
+ "tasks": [
+ {
+ "id": "20251119T215029.085279000",
+ "name": "",
+ "description": "",
+ "priority": "Medium",
+ "deadline": "",
+ "status_id": "todo",
+ "position": 0
+ },
+ {
+ "id": "20251119T215155.147601000",
+ "name": "qwef",
+ "description": "qwefeef",
+ "priority": "Medium",
+ "deadline": "2025-11-19",
+ "status_id": "done",
+ "position": 0
+ }
+ ]
+}
\ No newline at end of file
diff --git a/backend/main.go b/backend/main.go
index bca2a29..968bd93 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -1,13 +1,272 @@
package main
-import "net/http"
+import (
+ "encoding/json"
+ "errors"
+ "io/ioutil"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+)
+
+type Column struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+}
+
+type ApiTask struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Priority string `json:"priority"`
+ Deadline string `json:"deadline"`
+ StatusID string `json:"status_id"`
+ Position int `json:"position"`
+}
+
+// TaskPatch uses pointer fields so we can tell which fields were provided
+// in the incoming JSON and update only those. Absent fields remain unchanged.
+type TaskPatch struct {
+ Name *string `json:"name"`
+ Description *string `json:"description"`
+ Priority *string `json:"priority"`
+ Deadline *string `json:"deadline"`
+ StatusID *string `json:"status_id"`
+ Position *int `json:"position"`
+}
+
+type Database struct {
+ Kanban []Column `json:"kanban"`
+ Tasks []ApiTask `json:"tasks"`
+}
+
+type FileDB struct {
+ mu sync.RWMutex
+ db Database
+ dbPath string
+}
+
+func NewFileDB() *FileDB {
+ path := os.Getenv("DB_FILE")
+ if path == "" { path = "db.json" }
+ f := &FileDB{dbPath: path}
+ if err := f.load(); err != nil {
+ log.Printf("db load failed, initializing sample: %v", err)
+ f.db = Database{
+ Kanban: []Column{{ID: "todo", Title: "To Do"}, {ID: "inprogress", Title: "In Progress"}, {ID: "done", Title: "Done"}},
+ Tasks: []ApiTask{},
+ }
+ _ = f.save()
+ }
+ return f
+}
+
+func (f *FileDB) load() error {
+ f.mu.Lock(); defer f.mu.Unlock()
+ b, err := ioutil.ReadFile(f.dbPath)
+ if err != nil { return err }
+ var db Database
+ if err := json.Unmarshal(b, &db); err != nil { return err }
+ f.db = db
+ return nil
+}
+
+func (f *FileDB) save() error {
+ // ensure directory exists
+ if dir := filepath.Dir(f.dbPath); dir != "." && dir != "" { _ = os.MkdirAll(dir, 0755) }
+ b, err := json.MarshalIndent(f.db, "", " ")
+ if err != nil { return err }
+ return ioutil.WriteFile(f.dbPath, b, 0644)
+}
+
+// Columns
+func (f *FileDB) ListColumns() []Column { f.mu.RLock(); defer f.mu.RUnlock(); out := make([]Column, len(f.db.Kanban)); copy(out, f.db.Kanban); return out }
+func (f *FileDB) InsertColumn(title string) Column {
+ f.mu.Lock(); defer f.mu.Unlock()
+ id := randomID()
+ c := Column{ID: id, Title: strings.TrimSpace(title)}
+ f.db.Kanban = append(f.db.Kanban, c); _ = f.save(); return c
+}
+func (f *FileDB) UpdateColumn(id, title string) (Column, bool) {
+ f.mu.Lock(); defer f.mu.Unlock()
+ for i := range f.db.Kanban {
+ if f.db.Kanban[i].ID == id {
+ f.db.Kanban[i].Title = strings.TrimSpace(title)
+ _ = f.save(); return f.db.Kanban[i], true
+ }
+ }
+ return Column{}, false
+}
+func (f *FileDB) DeleteColumn(id string) bool {
+ f.mu.Lock(); defer f.mu.Unlock()
+ // remove column
+ idx := -1
+ for i := range f.db.Kanban { if f.db.Kanban[i].ID == id { idx = i; break } }
+ if idx == -1 { return false }
+ f.db.Kanban = append(f.db.Kanban[:idx], f.db.Kanban[idx+1:]...)
+ // cascade tasks
+ tasks := f.db.Tasks[:0]
+ for _, t := range f.db.Tasks { if t.StatusID != id { tasks = append(tasks, t) } }
+ f.db.Tasks = tasks
+ _ = f.save(); return true
+}
+
+// Tasks
+func (f *FileDB) ListTasks() []ApiTask { f.mu.RLock(); defer f.mu.RUnlock(); out := make([]ApiTask, len(f.db.Tasks)); copy(out, f.db.Tasks); return out }
+func (f *FileDB) InsertTask(in ApiTask) (ApiTask, error) {
+ f.mu.Lock(); defer f.mu.Unlock()
+ if strings.TrimSpace(in.Name) == "" { return ApiTask{}, errors.New("name required") }
+ if in.ID == "" { in.ID = randomID() }
+ // set position at end of column
+ max := -1
+ for _, t := range f.db.Tasks { if t.StatusID == in.StatusID && t.Position > max { max = t.Position } }
+ in.Position = max + 1
+ f.db.Tasks = append(f.db.Tasks, in)
+ return in, f.save()
+}
+func (f *FileDB) UpdateTask(id string, patch TaskPatch) (ApiTask, bool) {
+ f.mu.Lock(); defer f.mu.Unlock()
+ idx := -1
+ for i := range f.db.Tasks { if f.db.Tasks[i].ID == id { idx = i; break } }
+ if idx == -1 { return ApiTask{}, false }
+ curr := f.db.Tasks[idx]
+ if patch.Name != nil { curr.Name = strings.TrimSpace(*patch.Name) }
+ if patch.Description != nil { curr.Description = *patch.Description }
+ if patch.Priority != nil { curr.Priority = *patch.Priority }
+ if patch.Deadline != nil { curr.Deadline = *patch.Deadline }
+ // status move
+ if patch.StatusID != nil { curr.StatusID = *patch.StatusID }
+ // position update
+ if patch.Position != nil { curr.Position = *patch.Position }
+ f.db.Tasks[idx] = curr
+ _ = f.save(); return curr, true
+}
+func (f *FileDB) DeleteTask(id string) bool {
+ f.mu.Lock(); defer f.mu.Unlock()
+ idx := -1
+ for i := range f.db.Tasks { if f.db.Tasks[i].ID == id { idx = i; break } }
+ if idx == -1 { return false }
+ f.db.Tasks = append(f.db.Tasks[:idx], f.db.Tasks[idx+1:]...)
+ _ = f.save(); return true
+}
+
+func randomID() string { return time.Now().UTC().Format("20060102T150405.000000000") }
func main() {
- // Your code here
+ repo := NewFileDB()
+ mux := http.NewServeMux()
+ // Legacy root To-Do per openAPI remains
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { ToDoListHandler(w, r, repo) })
+ // Kanban columns
+ mux.HandleFunc("/api/kanban", func(w http.ResponseWriter, r *http.Request) { KanbanHandler(w, r, repo) })
+ mux.HandleFunc("/api/kanban/", func(w http.ResponseWriter, r *http.Request) { KanbanItemHandler(w, r, repo) })
+ // Tasks
+ mux.HandleFunc("/api/tasks", func(w http.ResponseWriter, r *http.Request) { TasksHandler(w, r, repo) })
+ mux.HandleFunc("/api/tasks/", func(w http.ResponseWriter, r *http.Request) { TaskHandler(w, r, repo) })
+
+ addr := ":8080"
+ if v := os.Getenv("PORT"); v != "" { addr = ":" + v }
+ log.Printf("Starting backend on %s", addr)
+ if err := http.ListenAndServe(addr, withCORS(mux)); err != nil { log.Fatalf("server error: %v", err) }
}
-func ToDoListHandler(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Access-Control-Allow-Origin", "*")
+func withCORS(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,PUT,DELETE,OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
+ if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent); return }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// Legacy ToDo for root
+type Todo struct { Title string `json:"title"`; Description string `json:"description"` }
+func ToDoListHandler(w http.ResponseWriter, r *http.Request, repo *FileDB) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.Method {
+ case http.MethodGet:
+ repo.mu.RLock(); out := make([]Todo, 0); repo.mu.RUnlock()
+ writeJSON(w, http.StatusOK, out); return
+ case http.MethodPost:
+ var in Todo
+ dec := json.NewDecoder(r.Body); dec.DisallowUnknownFields()
+ if err := dec.Decode(&in); err != nil { http.Error(w, "invalid input", http.StatusBadRequest); return }
+ writeJSON(w, http.StatusOK, in); return
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed); return
+ }
+}
+
+// Columns handlers
+func KanbanHandler(w http.ResponseWriter, r *http.Request, repo *FileDB) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.Method {
+ case http.MethodGet:
+ writeJSON(w, http.StatusOK, repo.ListColumns()); return
+ case http.MethodPost:
+ var in struct{ Title string `json:"title"` }
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil { http.Error(w, "invalid input", http.StatusBadRequest); return }
+ created := repo.InsertColumn(in.Title); writeJSON(w, http.StatusOK, created); return
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed); return
+ }
+}
+func KanbanItemHandler(w http.ResponseWriter, r *http.Request, repo *FileDB) {
+ w.Header().Set("Content-Type", "application/json")
+ id := strings.TrimPrefix(r.URL.Path, "/api/kanban/")
+ if id == "" { http.Error(w, "not found", http.StatusNotFound); return }
+ switch r.Method {
+ case http.MethodPatch, http.MethodPut:
+ var in struct{ Title string `json:"title"` }
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil { http.Error(w, "invalid input", http.StatusBadRequest); return }
+ updated, ok := repo.UpdateColumn(id, in.Title); if !ok { http.Error(w, "not found", http.StatusNotFound); return }
+ writeJSON(w, http.StatusOK, updated); return
+ case http.MethodDelete:
+ if ok := repo.DeleteColumn(id); !ok { http.Error(w, "not found", http.StatusNotFound); return }
+ w.WriteHeader(http.StatusNoContent); return
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed); return
+ }
+}
- // Your code here
+// Tasks handlers
+func TasksHandler(w http.ResponseWriter, r *http.Request, repo *FileDB) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.Method {
+ case http.MethodGet:
+ writeJSON(w, http.StatusOK, repo.ListTasks()); return
+ case http.MethodPost:
+ var in ApiTask
+ dec := json.NewDecoder(r.Body); dec.DisallowUnknownFields()
+ if err := dec.Decode(&in); err != nil { http.Error(w, "invalid input", http.StatusBadRequest); return }
+ created, err := repo.InsertTask(in); if err != nil { http.Error(w, err.Error(), http.StatusBadRequest); return }
+ writeJSON(w, http.StatusOK, created); return
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed); return
+ }
}
+func TaskHandler(w http.ResponseWriter, r *http.Request, repo *FileDB) {
+ w.Header().Set("Content-Type", "application/json")
+ id := strings.TrimPrefix(r.URL.Path, "/api/tasks/")
+ if id == "" { http.Error(w, "not found", http.StatusNotFound); return }
+ switch r.Method {
+ case http.MethodPatch, http.MethodPut:
+ var patch TaskPatch
+ dec := json.NewDecoder(r.Body); dec.DisallowUnknownFields()
+ if err := dec.Decode(&patch); err != nil { http.Error(w, "invalid input", http.StatusBadRequest); return }
+ updated, ok := repo.UpdateTask(id, patch); if !ok { http.Error(w, "not found", http.StatusNotFound); return }
+ writeJSON(w, http.StatusOK, updated); return
+ case http.MethodDelete:
+ if ok := repo.DeleteTask(id); !ok { http.Error(w, "not found", http.StatusNotFound); return }
+ w.WriteHeader(http.StatusNoContent); return
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed); return
+ }
+}
+
+func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.WriteHeader(status); _ = json.NewEncoder(w).Encode(v) }
diff --git a/frontend/.gitignore b/frontend/.gitignore
deleted file mode 100644
index a547bf3..0000000
--- a/frontend/.gitignore
+++ /dev/null
@@ -1,24 +0,0 @@
-# Logs
-logs
-*.log
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-pnpm-debug.log*
-lerna-debug.log*
-
-node_modules
-dist
-dist-ssr
-*.local
-
-# Editor directories and files
-.vscode/*
-!.vscode/extensions.json
-.idea
-.DS_Store
-*.suo
-*.ntvs*
-*.njsproj
-*.sln
-*.sw?
diff --git a/frontend/.nvmrc b/frontend/.nvmrc
deleted file mode 100644
index 9a2a0e2..0000000
--- a/frontend/.nvmrc
+++ /dev/null
@@ -1 +0,0 @@
-v20
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..54a2631
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,43 @@
+# Svelte + Vite
+
+This template should help get you started developing with Svelte in Vite.
+
+## Recommended IDE Setup
+
+[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
+
+## Need an official Svelte framework?
+
+Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
+
+## Technical considerations
+
+**Why use this over SvelteKit?**
+
+- It brings its own routing solution which might not be preferable for some users.
+- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
+
+This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
+
+Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
+
+**Why include `.vscode/extensions.json`?**
+
+Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
+
+**Why enable `checkJs` in the JS template?**
+
+It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
+
+**Why is HMR not preserving my local component state?**
+
+HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state).
+
+If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
+
+```js
+// store.js
+// An extremely simple external store
+import { writable } from 'svelte/store'
+export default writable(0)
+```
diff --git a/frontend/index.html b/frontend/index.html
index a557289..7d082ee 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1,16 +1,13 @@
-
-