diff --git a/ISSUES.md b/ISSUES.md deleted file mode 100644 index 2c958c0..0000000 --- a/ISSUES.md +++ /dev/null @@ -1,310 +0,0 @@ -# Initial Ticket Backlog - -Curated list of tickets to file in Gitea Issues. Copy each block into a new issue using the matching template. - ---- - -## ๐Ÿ—๏ธ Project Foundation - -### 1. Repository bootstrapping - -> **Template:** ๐Ÿ“‹ Task / Epic -> **Labels:** `task` -> **Priority:** P0 - -**Title:** `[P0] [Task] Bootstrap repo structure and tooling` - -- [ ] Add `.gitignore`, `.editorconfig`, `CONTRIBUTING.md` -- [ ] Set up branch protection on `main` -- [ ] Configure CI linting (shellcheck, markdownlint, yamllint) -- [ ] Add pre-commit hooks - ---- - -### 2. README & onboarding docs - -> **Template:** ๐Ÿ“‹ Task / Epic -> **Labels:** `documentation` -> **Priority:** P0 - -**Title:** `[P0] [Task] Write project README and onboarding guide` - -- [ ] High-level architecture overview -- [ ] Local development setup instructions -- [ ] Contribution workflow (branching, commits, reviews) -- [ ] Link to this tickets repo - ---- - -### 3. CI pipeline - -> **Template:** ๐Ÿ“‹ Task / Epic -> **Labels:** `task` -> **Priority:** P0 - -**Title:** `[P0] [Task] Set up CI pipeline` - -- [ ] Build step (compile / install deps) -- [ ] Lint step (static analysis) -- [ ] Test step (unit + integration) -- [ ] Status badge in README - ---- - -## ๐Ÿงช Testing - -### 4. Unit-test scaffolding - -> **Template:** ๐Ÿ“‹ Task / Epic -> **Labels:** `task` -> **Priority:** P1 - -**Title:** `[P1] [Task] Scaffold unit-test framework` - -- [ ] Choose and wire up a test runner -- [ ] Add a single passing smoke test -- [ ] Document how to run tests locally - ---- - -### 5. Integration tests - -> **Template:** ๐Ÿ“‹ Task / Epic -> **Labels:** `task` -> **Priority:** P1 - -**Title:** `[P1] [Task] Add integration / end-to-end test harness` - -- [ ] Spin up a local environment for tests -- [ ] Write 2-3 critical-path integration tests -- [ ] Wire into CI - ---- - -### 6. Test coverage reporting - -> **Template:** โœจ Feature Request -> **Labels:** `enhancement` -> **Priority:** P2 - -**Title:** `[P2] [Feature] Add test coverage reporting` - -> **Problem:** No visibility into what code paths have tests. -> **Solution:** Generate coverage reports in CI and block merges below a threshold (e.g. 80โ€ฏ%). - ---- - -## ๐Ÿ”’ Security & Quality - -### 7. Dependency scanning - -> **Template:** โœจ Feature Request -> **Labels:** `enhancement` -> **Priority:** P1 - -**Title:** `[P1] [Feature] Automated dependency vulnerability scanning` - -> **Problem:** Outdated or vulnerable dependencies slip through. -> **Solution:** Add [Dependabot](https://github.com/dependabot) (or Renovate) + a SAST scanner in CI. - ---- - -### 8. Secrets management - -> **Template:** ๐Ÿ“‹ Task / Epic -> **Labels:** `task` -> **Priority:** P0 - -**Title:** `[P0] [Task] Audit and secure secrets management` - -- [ ] Verify no secrets are committed to the repo -- [ ] Set up `.env` conventions -- [ ] Document how to inject secrets in dev and prod - ---- - -### 9. Linting & formatting - -> **Template:** ๐Ÿ“‹ Task / Epic -> **Labels:** `task` -> **Priority:** P1 - -**Title:** `[P1] [Task] Enforce consistent linting and formatting` - -- [ ] Choose and configure formatters / linters for each language in the stack -- [ ] Add pre-commit hook (or CI check) that blocks violations -- [ ] Format the existing codebase - ---- - -## ๐Ÿ“– Documentation - -### 10. API / interface docs - -> **Template:** ๐Ÿ“‹ Task / Epic -> **Labels:** `documentation` -> **Priority:** P1 - -**Title:** `[P1] [Task] Generate and publish API documentation` - -- [ ] Select a doc generator (e.g. OpenAPI, Sphinx, JSDoc) -- [ ] Add docstrings / annotations to public interfaces -- [ ] Publish to a static site or docs host - ---- - -### 11. Architecture Decision Records - -> **Template:** โœจ Feature Request -> **Labels:** `enhancement`, `documentation` -> **Priority:** P2 - -**Title:** `[P2] [Feature] Adopt Architecture Decision Records (ADRs)` - -> **Problem:** Design decisions are made in chat and forgotten. -> **Solution:** Maintain a `docs/adr/` directory with lightweight decision records. - ---- - -## ๐Ÿ› Placeholder Bugs (for testing the flow) - -### 12. Template: Bug Report - -> **Template:** ๐Ÿ› Bug Report -> **Labels:** `bug` -> **Priority:** P2 - -**Title:** `[P2] [Bug] Example bug โ€” page title wraps on small viewports` - -| Field | Value | -|---|---| -| **Steps** | Resize browser to 320โ€ฏpx wide, open the landing page. | -| **Expected** | Title stays on one line or truncates gracefully. | -| **Actual** | Title wraps and overlaps the nav bar. | - ---- - -### 13. Template: Bug Report - -> **Template:** ๐Ÿ› Bug Report -> **Labels:** `bug` -> **Priority:** P3 - -**Title:** `[P3] [Bug] Example bug โ€” console warns about missing key prop` - -| Field | Value | -|---|---| -| **Steps** | Open dev tools, navigate to the dashboard. | -| **Expected** | No React key warnings. | -| **Actual** | `Warning: Each child in a list should have a unique "key" prop.` | - ---- - -## ๐Ÿš€ Features - -### 14. Health-check endpoint - -> **Template:** โœจ Feature Request -> **Labels:** `enhancement` -> **Priority:** P1 - -**Title:** `[P1] [Feature] Add health-check endpoint` - -> **Problem:** No way for load balancers or monitoring to know if the service is alive. -> **Solution:** Expose `GET /health` returning `{"status":"ok"}` and a quick DB ping. - ---- - -### 15. Structured logging - -> **Template:** โœจ Feature Request -> **Labels:** `enhancement` -> **Priority:** P1 - -**Title:** `[P1] [Feature] Switch to structured (JSON) logging` - -> **Problem:** Logs are plain-text and hard to query. -> **Solution:** Emit JSON log lines with consistent keys (`level`, `message`, `timestamp`, `trace_id`). - ---- - -### 16. Feature flags - -> **Template:** โœจ Feature Request -> **Labels:** `enhancement` -> **Priority:** P2 - -**Title:** `[P2] [Feature] Introduce feature-flag system` - -> **Problem:** Can't safely ship partial or experimental features. -> **Solution:** Use a simple flag provider (env vars initially) to gate features. - ---- - -### 17. Observability / metrics - -> **Template:** โœจ Feature Request -> **Labels:** `enhancement` -> **Priority:** P2 - -**Title:** `[P2] [Feature] Export app metrics for monitoring` - -> **Problem:** Can't answer "is the system healthy?" without looking at logs. -> **Solution:** Export request duration, error rate, and DB query metrics. - ---- - -## ๐Ÿค Community & Maintenance - -### 18. CONTRIBUTING guide - -> **Template:** ๐Ÿ“‹ Task / Epic -> **Labels:** `documentation`, `good first issue` -> **Priority:** P1 - -**Title:** `[P1] [Task] Write CONTRIBUTING.md` - -- [ ] Code style and conventions -- [ ] PR template and review expectations -- [ ] Local development quick-start - ---- - -### 19. Issue triage process - -> **Template:** ๐Ÿ“‹ Task / Epic -> **Labels:** `task` -> **Priority:** P2 - -**Title:** `[P2] [Task] Define and document the issue triage process` - -- [ ] Weekly triage meeting cadence -- [ ] SLA for first response on new issues -- [ ] How issues move from `triage` โ†’ `accepted` - ---- - -### 20. Release checklist - -> **Template:** ๐Ÿ“‹ Task / Epic -> **Labels:** `task` -> **Priority:** P2 - -**Title:** `[P2] [Task] Create a release checklist** - -- [ ] Version bump convention -- [ ] CHANGELOG update step -- [ ] Tagging and release notes -- [ ] Post-deploy smoke-test commands - ---- - -## Filing Instructions - -1. Open the **Issues** tab in this repo. -2. Pick the template that matches the ticket above. -3. Copy-paste the title, description, labels, and priority. -4. Submit. - -Repeat until the entire backlog has been filed. Triage and assign each ticket in a follow-up pass. - diff --git a/README.md b/README.md deleted file mode 100644 index 89cad13..0000000 --- a/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# Tickets - -Central issue tracker and ticket backlog for the **Arcline Project**. - -All work is tracked as **Gitea Issues** in this repository. No external tooling required โ€” just open an issue. - ---- - -## Quick Start - -### Creating a Ticket - -1. Go to the **Issues** tab in this repo. -2. Click **New Issue**. -3. Pick a template: - - ๐Ÿ› **Bug Report** โ€” something is broken. - - โœจ **Feature Request** โ€” propose something new. - - ๐Ÿ“‹ **Task / Epic** โ€” a general task, spike, or umbrella issue. -4. Fill in the fields, attach labels, and submit. - -> The templates live in `.gitea/ISSUE_TEMPLATE/` if you want to tweak them. - -### Labels - -| Label | Purpose | -|----------------|----------------------------------------------| -| `bug` | Defect or unexpected behaviour | -| `enhancement` | New capability or improvement | -| `task` | General work item | -| `epic` | Large body of work spanning multiple issues | -| `triage` | Needs prioritisation / hasn't been reviewed | -| `documentation`| Docs, READMEs, comments | -| `good first issue` | Beginner-friendly starting point | -| `blocked` | Waiting on something else | -| `wontfix` | Acknowledged but not planned | - -### Prioritisation - -Prefix issue titles to signal urgency at a glance: - -- `[P0]` โ€” Drop everything. Critical / blocking. -- `[P1]` โ€” Important. Should be in the current iteration. -- `[P2]` โ€” Nice to have. Backlog. -- `[P3]` โ€” Someday / maybe. - -Example: `[P1] [Bug] Login page returns 500 on `\n` in username` - ---- - -## Workflow - -1. **Open** an issue using a template. -2. **Triage** โ€” assign labels, priority, milestone, and an owner. -3. **Work** โ€” branch off `main`, reference the issue in commits (`#42`). -4. **Close** โ€” when the work lands, close the issue (or let a PR close it automatically with `Closes #42`). - ---- - -## Backlog - -See [`ISSUES.md`](./ISSUES.md) for the initial curated backlog of tickets ready to be filed. - diff --git a/app.py b/app.py new file mode 100644 index 0000000..e2cabd6 --- /dev/null +++ b/app.py @@ -0,0 +1,150 @@ +"""Flask-based ticketing system web application.""" + +from flask import Flask, redirect, render_template, request, url_for + +from models import Ticket, TicketPriority, TicketStatus +from store import get_store + +app = Flask(__name__) + + +# โ”€โ”€ helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def _priorities() -> list[tuple[str, str]]: + return [(p.value, p.name.replace("_", " ").title()) for p in TicketPriority] + + +def _statuses() -> list[tuple[str, str]]: + return [(s.value, s.name.replace("_", " ").title()) for s in TicketStatus] + + +def _ticket_from_form() -> Ticket: + return Ticket( + title=request.form.get("title", "").strip(), + description=request.form.get("description", "").strip(), + priority=TicketPriority(request.form.get("priority", "medium")), + status=TicketStatus(request.form.get("status", "open")), + assignee=request.form.get("assignee", "").strip(), + project=request.form.get("project", "").strip(), + ) + + +# โ”€โ”€ routes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +@app.route("/") +def index() -> str: + store = get_store() + status_filter = request.args.get("status", "") + priority_filter = request.args.get("priority", "") + project_filter = request.args.get("project", "") + search = request.args.get("search", "") + + tickets = store.list_tickets( + status=status_filter or None, + priority=priority_filter or None, + search=search or None, + project=project_filter or None, + ) + counts = store.count_by_status() + projects = store.get_projects() + + return render_template( + "index.html", + tickets=tickets, + status_filter=status_filter, + priority_filter=priority_filter, + project_filter=project_filter, + search=search, + counts=counts, + priorities=_priorities(), + statuses=_statuses(), + projects=projects, + ) + + +@app.route("/tickets/new", methods=["GET", "POST"]) +def create_ticket() -> str: + if request.method == "POST": + ticket = _ticket_from_form() + if not ticket.title: + return render_template( + "create.html", + priorities=_priorities(), + statuses=_statuses(), + ticket=ticket, + error="Title is required.", + ) + store = get_store() + store.create_ticket(ticket) + return redirect(url_for("index")) + + return render_template( + "create.html", + priorities=_priorities(), + statuses=_statuses(), + ticket=None, + ) + + +@app.route("/tickets/") +def view_ticket(ticket_id: int) -> str: + store = get_store() + ticket = store.get_ticket(ticket_id) + if ticket is None: + return "Ticket not found", 404 + return render_template( + "detail.html", + ticket=ticket, + priorities=_priorities(), + statuses=_statuses(), + ) + + +@app.route("/tickets//edit", methods=["GET", "POST"]) +def edit_ticket(ticket_id: int) -> str: + store = get_store() + ticket = store.get_ticket(ticket_id) + if ticket is None: + return "Ticket not found", 404 + + if request.method == "POST": + title = request.form.get("title", "").strip() + if not title: + return render_template( + "edit.html", + ticket=ticket, + priorities=_priorities(), + statuses=_statuses(), + error="Title is required.", + ) + updates = { + "title": title, + "description": request.form.get("description", "").strip(), + "priority": TicketPriority(request.form.get("priority", "medium")), + "status": TicketStatus(request.form.get("status", "open")), + "assignee": request.form.get("assignee", "").strip(), + "project": request.form.get("project", "").strip(), + } + store.update_ticket(ticket_id, updates) + return redirect(url_for("view_ticket", ticket_id=ticket_id)) + + return render_template( + "edit.html", + ticket=ticket, + priorities=_priorities(), + statuses=_statuses(), + ) + + +@app.route("/tickets//delete", methods=["POST"]) +def delete_ticket(ticket_id: int) -> str: + store = get_store() + store.delete_ticket(ticket_id) + return redirect(url_for("index")) + + +# โ”€โ”€ entrypoint โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +if __name__ == "__main__": + app.run(debug=True, host="0.0.0.0", port=5000) + diff --git a/data/tickets.json b/data/tickets.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/data/tickets.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/models.py b/models.py new file mode 100644 index 0000000..64e67b1 --- /dev/null +++ b/models.py @@ -0,0 +1,54 @@ +"""Ticket data model for the ticketing system.""" + +from dataclasses import dataclass, field, asdict +from datetime import datetime, timezone +from enum import Enum +from typing import Optional + + +class TicketStatus(str, Enum): + OPEN = "open" + IN_PROGRESS = "in_progress" + RESOLVED = "resolved" + CLOSED = "closed" + + +class TicketPriority(str, Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +@dataclass +class Ticket: + title: str + description: str + priority: TicketPriority = TicketPriority.MEDIUM + status: TicketStatus = TicketStatus.OPEN + assignee: str = "" + project: str = "" + id: Optional[int] = None + created_at: str = "" + updated_at: str = "" + + def __post_init__(self) -> None: + now = datetime.now(timezone.utc).isoformat() + if not self.created_at: + self.created_at = now + if not self.updated_at: + self.updated_at = now + + def to_dict(self) -> dict: + data = asdict(self) + data["priority"] = self.priority.value + data["status"] = self.status.value + return data + + @classmethod + def from_dict(cls, data: dict) -> "Ticket": + data = dict(data) # shallow copy + data["priority"] = TicketPriority(data["priority"]) + data["status"] = TicketStatus(data["status"]) + return cls(**data) + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6a855f7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +jinja2>=3.1 + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..88febd6 --- /dev/null +++ b/static/style.css @@ -0,0 +1,534 @@ +/* โ”€โ”€ Reset & Base โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + /* Arcline brand palette โ€” dark theme */ + --clr-bg: #0a0a10; + --clr-surface: #0f0f19; + --clr-surface-raised: #161626; + --clr-text: #e2e8f0; + --clr-heading: #f1f5f9; + --clr-muted: #7c8ba0; + --clr-border: #1c2030; + --clr-accent: #2dd4bf; + --clr-accent-hover: #14b8a6; + --clr-accent-dim: rgba(45, 212, 191, 0.10); + --clr-accent-glow: rgba(45, 212, 191, 0.18); + --clr-danger: #ef4444; + --clr-danger-hover: #dc2626; + --clr-danger-bg: rgba(239, 68, 68, 0.10); + --clr-open: #2dd4bf; + --clr-in-progress: #f59e0b; + --clr-resolved: #22c55e; + --clr-closed: #64748b; + --clr-low: #22c55e; + --clr-medium: #f59e0b; + --clr-high: #f97316; + --clr-critical: #ef4444; + --radius: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --shadow-sm: 0 1px 3px rgba(0,0,0,.45); + --shadow: 0 4px 24px rgba(0,0,0,.5); + --shadow-lg: 0 8px 40px rgba(0,0,0,.6); + --max-width: 960px; +} + +body { + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen, Ubuntu, Cantarell, sans-serif; + background: var(--clr-bg); + background-image: + radial-gradient(circle at 50% 0%, rgba(45, 212, 191, 0.03) 0%, transparent 50%), + linear-gradient(rgba(45, 212, 191, 0.015) 1px, transparent 1px), + linear-gradient(90deg, rgba(45, 212, 191, 0.015) 1px, transparent 1px); + background-size: 100% 100%, 60px 60px, 60px 60px; + color: var(--clr-text); + line-height: 1.65; + min-height: 100vh; + display: flex; + flex-direction: column; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { color: var(--clr-accent); text-decoration: none; transition: color 0.18s, opacity 0.18s; } +a:hover { color: var(--clr-accent-hover); text-decoration: none; } + +/* โ”€โ”€ Layout โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.container { + max-width: var(--max-width); + margin: 0 auto; + padding: 0 1.5rem; +} + +main.container { + flex: 1; + padding-top: 2.5rem; + padding-bottom: 5rem; +} + +/* โ”€โ”€ Header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.site-header { + background: var(--clr-surface); + border-bottom: 1px solid var(--clr-border); + padding: 0.9rem 0; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} +.site-header .container { + display: flex; + align-items: center; + justify-content: space-between; +} +.logo { + font-size: 1.1rem; + font-weight: 700; + color: var(--clr-heading); + letter-spacing: -0.4px; +} +.logo .accent { color: var(--clr-accent); } +.logo:hover { color: var(--clr-heading); text-decoration: none; } +.logo:hover .accent { color: var(--clr-accent-hover); } +.site-header nav { display: flex; align-items: center; gap: 1rem; } +.site-header nav a { + color: var(--clr-muted); + font-size: 0.88rem; + font-weight: 500; + letter-spacing: -0.1px; +} +.site-header nav a:hover { color: var(--clr-text); } + +/* โ”€โ”€ Footer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.site-footer { + background: var(--clr-surface); + border-top: 1px solid var(--clr-border); + padding: 1.5rem 0; + text-align: center; + font-size: 0.8rem; + color: var(--clr-muted); + margin-top: auto; + letter-spacing: -0.1px; +} + +/* โ”€โ”€ Buttons โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.55rem 1.1rem; + border: 1px solid transparent; + border-radius: var(--radius); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.18s, color 0.18s, border-color 0.18s, box-shadow 0.18s, transform 0.12s; + font-family: inherit; + letter-spacing: -0.15px; +} +.btn:hover { text-decoration: none; } +.btn:active { transform: scale(0.985); } + +.btn-primary { + background: var(--clr-accent); + color: #0a0a10; + border-color: var(--clr-accent); + font-weight: 600; +} +.btn-primary:hover { + background: var(--clr-accent-hover); + border-color: var(--clr-accent-hover); + color: #0a0a10; + box-shadow: 0 0 28px rgba(45, 212, 191, 0.3); +} + +.btn-secondary { + background: var(--clr-surface-raised); + border-color: var(--clr-border); + color: var(--clr-text); +} +.btn-secondary:hover { + background: var(--clr-border); + border-color: rgba(45, 212, 191, 0.25); + box-shadow: 0 0 16px rgba(45, 212, 191, 0.06); +} + +.btn-danger { + background: var(--clr-danger); + color: #fff; + border-color: var(--clr-danger); +} +.btn-danger:hover { + background: var(--clr-danger-hover); + border-color: var(--clr-danger-hover); + box-shadow: 0 0 28px rgba(239, 68, 68, 0.25); +} + +.btn-ghost { + background: transparent; + color: var(--clr-muted); + border-color: transparent; +} +.btn-ghost:hover { + color: var(--clr-text); + background: var(--clr-surface-raised); + border-color: var(--clr-border); +} + +.btn-sm { padding: 0.3rem 0.7rem; font-size: 0.8rem; } + +/* โ”€โ”€ Inputs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.input { + width: 100%; + padding: 0.6rem 0.85rem; + border: 1px solid var(--clr-border); + border-radius: var(--radius); + font-size: 0.9rem; + font-family: inherit; + background: var(--clr-surface); + color: var(--clr-text); + transition: border-color 0.18s, box-shadow 0.18s, background 0.18s; + letter-spacing: -0.1px; +} +.input::placeholder { color: var(--clr-muted); opacity: 0.55; } +.input:focus { + outline: none; + border-color: var(--clr-accent); + box-shadow: 0 0 0 3px var(--clr-accent-dim), 0 0 20px rgba(45, 212, 191, 0.08); + background: var(--clr-surface-raised); +} +.search-input { max-width: 320px; } +.filter-select { max-width: 180px; } +textarea.input { resize: vertical; } +select.input { + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2394a3b8' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2.25rem; +} +select.input option { + background: var(--clr-surface-raised); + color: var(--clr-text); +} + +/* โ”€โ”€ Forms โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.form-container { + background: var(--clr-surface); + border: 1px solid var(--clr-border); + border-radius: var(--radius-xl); + padding: 2.25rem; + box-shadow: var(--shadow-sm); +} +.form-group { margin-bottom: 1.35rem; } +.form-group label { + display: block; + font-weight: 600; + font-size: 0.84rem; + margin-bottom: 0.4rem; + color: var(--clr-muted); + letter-spacing: -0.1px; +} +.required { color: var(--clr-danger); } +.form-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; } +.form-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1.75rem; + padding-top: 1.35rem; + border-top: 1px solid var(--clr-border); +} + +/* โ”€โ”€ Alerts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.alert { + padding: 0.85rem 1.1rem; + border-radius: var(--radius); + margin-bottom: 1.5rem; + font-size: 0.85rem; + letter-spacing: -0.1px; +} +.alert-error { + background: rgba(245, 158, 11, 0.10); + border: 1px solid rgba(245, 158, 11, 0.25); + color: #fbbf24; +} + +/* โ”€โ”€ Page Header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 2rem; + gap: 1rem; +} +.page-header h1 { + font-size: 1.6rem; + color: var(--clr-heading); + font-weight: 800; + letter-spacing: -0.5px; +} +.action-buttons { display: flex; gap: 0.5rem; align-items: center; } +.back-link { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.82rem; + color: var(--clr-muted); + margin-bottom: 0.35rem; + letter-spacing: -0.1px; +} +.back-link:hover { color: var(--clr-accent); } + +/* โ”€โ”€ Stats Bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.stats-bar { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} +.stat { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.7rem 1.35rem; + background: var(--clr-surface); + border: 1px solid var(--clr-border); + border-radius: var(--radius); + transition: background 0.18s, border-color 0.18s, box-shadow 0.18s; + min-width: 82px; +} +.stat:hover { + text-decoration: none; + border-color: rgba(45, 212, 191, 0.35); + background: var(--clr-surface-raised); + box-shadow: 0 0 20px rgba(45, 212, 191, 0.06); +} +.stat.active { + border-color: var(--clr-accent); + background: var(--clr-accent-dim); + box-shadow: 0 0 24px rgba(45, 212, 191, 0.10); +} +.stat-num { + font-size: 1.35rem; + font-weight: 800; + color: var(--clr-heading); + letter-spacing: -0.8px; + font-variant-numeric: tabular-nums; +} +.stat.active .stat-num { color: var(--clr-accent); } +.stat-label { + font-size: 0.7rem; + color: var(--clr-muted); + text-transform: uppercase; + letter-spacing: 0.6px; + font-weight: 600; +} + +/* โ”€โ”€ Filters โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.filters { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; + align-items: center; +} + +/* โ”€โ”€ Ticket List โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.ticket-list { display: flex; flex-direction: column; gap: 0.75rem; } + +.ticket-card { + display: block; + background: var(--clr-surface); + border: 1px solid var(--clr-border); + border-radius: var(--radius-lg); + padding: 1.35rem; + transition: border-color 0.2s, box-shadow 0.2s, background 0.2s, transform 0.15s; + color: var(--clr-text); + box-shadow: var(--shadow-sm); +} +.ticket-card:hover { + text-decoration: none; + border-color: rgba(45, 212, 191, 0.4); + box-shadow: 0 0 36px rgba(45, 212, 191, 0.12), var(--shadow); + background: var(--clr-surface-raised); + transform: translateY(-1px); +} +.ticket-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.55rem; +} +.ticket-id { + font-size: 0.76rem; + color: var(--clr-accent); + font-weight: 600; + font-family: "SF Mono", "Fira Code", "JetBrains Mono", "Cascadia Code", monospace; + letter-spacing: 0.5px; +} +.ticket-card-title { + font-size: 1.12rem; + margin-bottom: 0.4rem; + color: var(--clr-heading); + font-weight: 700; + letter-spacing: -0.3px; +} +.ticket-card-desc { + font-size: 0.88rem; + color: var(--clr-muted); + margin-bottom: 0.85rem; + line-height: 1.6; +} +.ticket-card-meta { + display: flex; + align-items: center; + gap: 0.85rem; + font-size: 0.78rem; +} +.assignee { color: var(--clr-muted); } +.date { color: var(--clr-muted); } + +/* โ”€โ”€ Badges โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.badge { + display: inline-flex; + align-items: center; + padding: 0.22rem 0.65rem; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 700; + text-transform: capitalize; + letter-spacing: -0.1px; +} + +/* status badges */ +.badge-open { background: rgba(45, 212, 191, 0.14); color: var(--clr-open); } +.badge-in_progress { background: rgba(245, 158, 11, 0.14); color: var(--clr-in-progress); } +.badge-resolved { background: rgba(34, 197, 94, 0.14); color: var(--clr-resolved); } +.badge-closed { background: rgba(100, 116, 139, 0.14); color: var(--clr-closed); } + +/* priority badges */ +.badge-priority { text-transform: uppercase; letter-spacing: 0.6px; font-size: 0.66rem; } +.badge-p-low { background: rgba(34, 197, 94, 0.14); color: var(--clr-low); } +.badge-p-medium { background: rgba(245, 158, 11, 0.14); color: var(--clr-medium); } +.badge-p-high { background: rgba(249, 115, 22, 0.14); color: var(--clr-high); } +.badge-p-critical { background: rgba(239, 68, 68, 0.14); color: var(--clr-critical); } + +/* project badge */ +.badge-project { background: rgba(99, 102, 241, 0.14); color: #a5b4fc; } + +/* โ”€โ”€ Ticket Detail โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.ticket-detail-grid { + display: grid; + grid-template-columns: 1fr 280px; + gap: 1.5rem; + align-items: start; +} + +.ticket-main { + background: var(--clr-surface); + border: 1px solid var(--clr-border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-sm); +} + +.ticket-section { padding: 1.65rem; } +.ticket-section + .ticket-section { + border-top: 1px solid var(--clr-border); +} +.ticket-section h3 { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.7px; + color: var(--clr-muted); + margin-bottom: 0.85rem; + font-weight: 700; +} + +.ticket-description { + line-height: 1.85; + white-space: pre-wrap; + word-break: break-word; + color: var(--clr-text); + font-size: 0.95rem; +} + +/* โ”€โ”€ Sidebar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.ticket-sidebar { + position: sticky; + top: 1.5rem; +} + +.ticket-meta-card { + background: var(--clr-surface); + border: 1px solid var(--clr-border); + border-radius: var(--radius-xl); + padding: 1.35rem; + box-shadow: var(--shadow-sm); +} + +.meta-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.6rem 0; + font-size: 0.88rem; +} + +.meta-label { + font-weight: 600; + color: var(--clr-muted); + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +hr { + border: none; + border-top: 1px solid var(--clr-border); + margin: 0.55rem 0; +} + +/* โ”€โ”€ Empty State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.empty-state { + text-align: center; + padding: 5rem 1rem; + color: var(--clr-muted); +} +.empty-state p { + margin-bottom: 1.25rem; + font-size: 1.05rem; + color: var(--clr-muted); + letter-spacing: -0.2px; +} + +/* โ”€โ”€ Utility โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.muted { color: var(--clr-muted); } + +/* โ”€โ”€ Responsive โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +@media (max-width: 768px) { + .ticket-detail-grid { grid-template-columns: 1fr; } + .form-row { grid-template-columns: 1fr; } + .page-header { flex-direction: column; } + .stats-bar { justify-content: center; } +} + diff --git a/store.py b/store.py new file mode 100644 index 0000000..1ab9789 --- /dev/null +++ b/store.py @@ -0,0 +1,134 @@ +"""JSON-based data store for ticket persistence.""" + +import json +import os +import threading +from pathlib import Path +from typing import Optional + +from models import Ticket + +DATA_DIR = Path(__file__).parent / "data" +DATA_FILE = DATA_DIR / "tickets.json" + + +class TicketStore: + """Thread-safe JSON-backed ticket store.""" + + def __init__(self, filepath: Path = DATA_FILE) -> None: + self._filepath = filepath + self._lock = threading.Lock() + self._next_id = 1 + self._tickets: dict[int, Ticket] = {} + self._load() + + # โ”€โ”€ persistence โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def _load(self) -> None: + if not self._filepath.exists(): + self._save() + return + with open(self._filepath) as fh: + raw: list[dict] = json.load(fh) + + self._tickets.clear() + max_id = 0 + for item in raw: + ticket = Ticket.from_dict(item) + self._tickets[ticket.id] = ticket + if ticket.id and ticket.id > max_id: + max_id = ticket.id + self._next_id = max_id + 1 + + def _save(self) -> None: + self._filepath.parent.mkdir(parents=True, exist_ok=True) + data = [t.to_dict() for t in sorted(self._tickets.values(), key=lambda t: t.id or 0)] + with open(self._filepath, "w") as fh: + json.dump(data, fh, indent=2) + + # โ”€โ”€ CRUD โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def list_tickets( + self, + status: Optional[str] = None, + priority: Optional[str] = None, + search: Optional[str] = None, + project: Optional[str] = None, + ) -> list[Ticket]: + tickets = list(self._tickets.values()) + + if status: + tickets = [t for t in tickets if t.status.value == status] + if priority: + tickets = [t for t in tickets if t.priority.value == priority] + if project: + tickets = [t for t in tickets if t.project.lower() == project.lower()] + if search: + q = search.lower() + tickets = [ + t + for t in tickets + if q in t.title.lower() + or q in t.description.lower() + or q in t.assignee.lower() + or q in t.project.lower() + ] + + return sorted(tickets, key=lambda t: t.id or 0, reverse=True) + + def get_ticket(self, ticket_id: int) -> Optional[Ticket]: + return self._tickets.get(ticket_id) + + def create_ticket(self, ticket: Ticket) -> Ticket: + with self._lock: + ticket.id = self._next_id + self._next_id += 1 + self._tickets[ticket.id] = ticket + self._save() + return ticket + + def update_ticket(self, ticket_id: int, updates: dict) -> Optional[Ticket]: + ticket = self._tickets.get(ticket_id) + if ticket is None: + return None + + with self._lock: + for key, value in updates.items(): + if hasattr(ticket, key): + setattr(ticket, key, value) + from datetime import datetime, timezone + + ticket.updated_at = datetime.now(timezone.utc).isoformat() + self._save() + return ticket + + def delete_ticket(self, ticket_id: int) -> bool: + with self._lock: + if ticket_id in self._tickets: + del self._tickets[ticket_id] + self._save() + return True + return False + + def count_by_status(self) -> dict[str, int]: + counts: dict[str, int] = {} + for t in self._tickets.values(): + counts[t.status.value] = counts.get(t.status.value, 0) + 1 + return counts + + def get_projects(self) -> list[str]: + """Return a sorted list of unique, non-empty project names.""" + projects = {t.project.strip() for t in self._tickets.values() if t.project.strip()} + return sorted(projects) + + +# Global singleton store +_store: Optional[TicketStore] = None + + +def get_store() -> TicketStore: + global _store + if _store is None: + _store = TicketStore() + return _store + diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..c7aaccb --- /dev/null +++ b/templates/base.html @@ -0,0 +1,31 @@ + + + + + + {% block title %}Arcline Tickets{% endblock %} + + + + + +
+ {% block content %}{% endblock %} +
+ +
+
+

Arcline Tickets — Built with Python, Flask & Jinja2

+
+
+ + + diff --git a/templates/create.html b/templates/create.html new file mode 100644 index 0000000..7d3280b --- /dev/null +++ b/templates/create.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% block title %}New Ticket{% endblock %} + +{% block content %} + + +
+ {% if error %} +
{{ error }}
+ {% endif %} + +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ Cancel + +
+
+
+{% endblock %} + diff --git a/templates/detail.html b/templates/detail.html new file mode 100644 index 0000000..91420b7 --- /dev/null +++ b/templates/detail.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% block title %}Ticket #{{ ticket.id }}{% endblock %} + +{% block content %} + + +
+
+
+

Description

+
+ {% if ticket.description %} +

{{ ticket.description }}

+ {% else %} +

No description provided.

+ {% endif %} +
+
+
+ + +
+{% endblock %} + diff --git a/templates/edit.html b/templates/edit.html new file mode 100644 index 0000000..680857d --- /dev/null +++ b/templates/edit.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% block title %}Edit Ticket #{{ ticket.id }}{% endblock %} + +{% block content %} + + +
+ {% if error %} +
{{ error }}
+ {% endif %} + +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ Cancel + +
+
+
+{% endblock %} + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..6a28b66 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} +{% block title %}Tickets{% endblock %} + +{% block content %} + + +{# โ”€โ”€ stats bar โ”€โ”€ #} +
+ + {{ tickets|length }} + Total + + {% for val, label in statuses %} + + {{ counts.get(val, 0) }} + {{ label }} + + {% endfor %} +
+ +{# โ”€โ”€ filters โ”€โ”€ #} +
+ + + {% if projects %} + + {% endif %} + + {% if status_filter or priority_filter or search or project_filter %} + Clear + {% endif %} +
+ +{# โ”€โ”€ ticket list โ”€โ”€ #} +{% if tickets %} + +{% else %} +
+

No tickets found.

+ Create the first ticket +
+{% endif %} +{% endblock %} +