Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,41 @@ Every module **must** have:
- Depend on behaviours, not concrete implementations
- Keep web layer thin — controllers delegate to contexts

## UI Components

### Component-first development

Before writing inline HTML in a LiveView template, check `CoreComponents` for an existing component. Common components:

| Category | Components |
|----------|-----------|
| Layout | `card`, `table`, `table_container`, `header` |
| Forms | `input`, `simple_form`, `multi_select`, `date_range_picker`, `date_picker`, `search_input` |
| Display | `badge`, `icon`, `pagination`, `empty_state` |
| Filters | `multi_select`, `date_range_picker`, `search_input`, `reset_filters_button` |
| Actions | `button` (variants: primary, outline, outline-destructive, ghost, destructive, success, warning) |

Domain-specific components live in dedicated modules:
- `InvoiceComponents` — status/type/category/payment badges, format helpers, invoice detail table
- `CertificateComponents` — certificate expiry alerts
- `SettingsComponents` — settings page sidebar layout

### When to extract a component

- **3+ identical occurrences** of the same HTML across views → extract to `CoreComponents`
- **Domain-agnostic** (table wrappers, empty states, inputs) → `CoreComponents` (globally imported)
- **Domain-specific** (invoice badges, format helpers) → domain module (e.g., `InvoiceComponents`)
- **Single-use** or highly context-dependent → keep inline

### Component conventions

- Every component must have `@doc`, `@spec`, and declarative `attr`/`slot` annotations
- Support a `class` attr for caller customization when reasonable
- Use `slot :inner_block` for content projection
- Use `@rest` (`:global`) for forwarding HTML attributes like `data-testid`

See `docs/adr/0043-component-driven-ui.md` for the architectural decision.

## TDD Workflow

Every feature starts with a test:
Expand Down
19 changes: 19 additions & 0 deletions assets/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -241,4 +241,23 @@ input[type="checkbox"].checkbox {
/* Make LiveView wrapper divs transparent for layout */
[data-phx-session], [data-phx-teleported-src] { display: contents }

/* Cally calendar: differentiate "today" from selected dates.
Default daisyUI fills today with --color-primary which looks too similar to selected.
Instead: primary-colored bold text with underline indicator.
The "selected" rule restores the filled style when today is also selected. */
.cally ::part(button day today) {
background: transparent !important;
color: var(--color-primary) !important;
font-weight: 700;
text-decoration: underline;
text-decoration-thickness: 3px;
text-underline-offset: 3px;
}

.cally ::part(selected today) {
background: var(--color-base-content) !important;
color: var(--color-base-100) !important;
text-decoration: none;
}

/* This file is for your main application CSS */
151 changes: 150 additions & 1 deletion assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {LiveSocket} from "phoenix_live_view"
import {hooks as colocatedHooks} from "phoenix-colocated/ksef_hub"
import topbar from "../vendor/topbar"
import Chart from "../vendor/chart.js"
import "../vendor/cally.js"

// Chart color palettes
const DONUT_COLORS = [
Expand Down Expand Up @@ -172,11 +173,159 @@ const LocalTime = {
}
}

// Shared calendar picker hook logic.
// calendarSelector: CSS selector for the Cally web component
// onSelect: (hook, calendarValue) => void — updates hidden inputs from the selected value
function calendarPickerHook(calendarSelector, onSelect) {
return {
mounted() {
this.calendar = this.el.querySelector(calendarSelector)
this.trigger = this.el.querySelector("[data-trigger]")
this.popover = this.el.querySelector("[data-popover]")

this.trigger.addEventListener("click", (e) => {
e.preventDefault()
this.popover.classList.toggle("hidden")
})

this._onOutsideClick = (e) => {
if (!this.el.contains(e.target)) {
this.popover.classList.add("hidden")
}
}
document.addEventListener("click", this._onOutsideClick)

this.calendar.addEventListener("change", () => {
onSelect(this, this.calendar.value)
this.popover.classList.add("hidden")
})
},

destroyed() {
document.removeEventListener("click", this._onOutsideClick)
}
}
}

const DateRangePicker = calendarPickerHook("calendar-range", (hook, value) => {
if (value && value.includes("/")) {
const [from, to] = value.split("/")
const fromInput = hook.el.querySelector("[data-from]")
const toInput = hook.el.querySelector("[data-to]")
fromInput.value = from
toInput.value = to
fromInput.dispatchEvent(new Event("input", { bubbles: true }))
}
})

const DatePicker = calendarPickerHook("calendar-date", (hook, value) => {
const input = hook.el.querySelector("[data-value]")
input.value = value
input.dispatchEvent(new Event("input", { bubbles: true }))
})

const MONTH_LABELS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]

const MonthRangePicker = {
mounted() {
this.trigger = this.el.querySelector("[data-trigger]")
this.popover = this.el.querySelector("[data-popover]")
this.yearEl = this.el.querySelector("[data-year]")
this.labelEl = this.el.querySelector("[data-label]")
this.fromInput = this.el.querySelector("[data-from]")
this.toInput = this.el.querySelector("[data-to]")
this.monthButtons = this.el.querySelectorAll("[data-month]")

this.year = parseInt(this.yearEl.textContent)
this.from = this.el.dataset.from || null
this.to = this.el.dataset.to || null
this.picking = null // null = pick from next, "to" = pick to next

this.trigger.addEventListener("click", (e) => {
e.preventDefault()
this.popover.classList.toggle("hidden")
})

this._onOutsideClick = (e) => {
if (!this.el.contains(e.target)) this.popover.classList.add("hidden")
}
document.addEventListener("click", this._onOutsideClick)

this.el.querySelector("[data-prev-year]").addEventListener("click", () => {
this.year--
this.yearEl.textContent = this.year
this.highlightMonths()
})

this.el.querySelector("[data-next-year]").addEventListener("click", () => {
this.year++
this.yearEl.textContent = this.year
this.highlightMonths()
})

this.monthButtons.forEach(btn => {
btn.addEventListener("click", () => {
const ym = this.year + "-" + btn.dataset.month
if (!this.picking) {
this.from = ym
this.to = null
this.picking = "to"
} else {
this.to = ym
this.picking = null
// swap if from > to
if (this.from > this.to) [this.from, this.to] = [this.to, this.from]
// commit
this.fromInput.value = this.from
this.toInput.value = this.to
this.fromInput.dispatchEvent(new Event("input", { bubbles: true }))
this.updateLabel()
this.popover.classList.add("hidden")
}
this.highlightMonths()
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

this.highlightMonths()
},

highlightMonths() {
this.monthButtons.forEach(btn => {
const ym = this.year + "-" + btn.dataset.month
const inRange = this.from && this.to && ym >= this.from && ym <= this.to
const isEndpoint = ym === this.from || ym === this.to
const isPartialFrom = this.picking === "to" && ym === this.from

btn.className = "h-8 rounded-md text-xs font-medium transition-colors cursor-pointer " +
(isEndpoint || isPartialFrom
? "bg-base-content text-base-100"
: inRange
? "bg-base-content/20"
: "hover:bg-base-200")
})
},

updateLabel() {
if (this.from && this.to) {
this.labelEl.textContent = this.fmtMonth(this.from) + " – " + this.fmtMonth(this.to)
}
},

fmtMonth(ym) {
const [y, m] = ym.split("-")
return MONTH_LABELS[parseInt(m) - 1] + " " + y
},

destroyed() {
document.removeEventListener("click", this._onOutsideClick)
}
}

const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks, ExpenseBarChart, CategoryDonutChart, MultiSelectSearch, LocalTime},
hooks: {...colocatedHooks, ExpenseBarChart, CategoryDonutChart, MultiSelectSearch, LocalTime, DateRangePicker, DatePicker, MonthRangePicker},
})

// Show progress bar on live navigation and form submits
Expand Down
Loading
Loading