Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
bbd2396
Add Tailwind CSS v4 + redesign sign-in page (casadesign)
Jul 9, 2026
51b21e4
Use Figtree instead of Inter across the design system
Jul 9, 2026
d48c24a
Add design.md design system + record casadesign workflow
Jul 9, 2026
4edf54b
Add Tailwind app-shell layout (casa_app)
Jul 9, 2026
898942d
Redesign supervisor landing as a triage dashboard
Jul 9, 2026
15e4cac
Refine supervisor dashboard: initials, page title, WCAG + responsive
Jul 9, 2026
3409005
Show user names without honorific prefixes (display_person helper)
Jul 9, 2026
5d22005
Remove redundant sidebar identity block
Jul 9, 2026
a238ced
Drop the sidebar logo mark — org name only
Jul 9, 2026
b3cc06e
Redesign remaining auth pages onto the Tailwind casa_auth layout
Jul 9, 2026
82e1eb1
Strip honorifics from names across legacy pages + datatables
Jul 9, 2026
2ade255
Shorten dashboard subtitle (drop em-dash and redundant org name)
Jul 9, 2026
8cb66a6
test(dashboard): expect supervisor dashboard to render, not redirect
Jul 10, 2026
51024ba
feat(notifications): migrate notifications page to the Tailwind casa_…
Jul 10, 2026
c9e0091
docs(design): make design.md the permanent design-system record + add…
Jul 10, 2026
1c0e204
feat(profile): migrate edit-profile page to the Tailwind casa_app shell
Jul 10, 2026
943b0d9
docs(design): record Form errors + Disclosure components; mark edit p…
Jul 10, 2026
2111aef
feat(shell): show the user's role in the account menu; refine dashboa…
Jul 10, 2026
867d38f
feat(shell): drop icon from role badge and colour-code it by role
Jul 10, 2026
2d9d4c6
refactor(design): sentence-case labels, streamline error summary, ado…
Jul 10, 2026
1235e35
refactor(profile): collapse form-error summary to one streamlined line
Jul 10, 2026
8ec4fa1
feat(dashboard): add the volunteer landing dashboard
Jul 10, 2026
28c2226
feat(dashboard): add the admin org-wide dashboard
Jul 10, 2026
e5e74dd
style(dashboard): give the roster/cases table cards a bottom inset
Jul 10, 2026
88a2759
style(notifications): inset the list card so rows clear the rounded c…
Jul 10, 2026
05dfa2c
feat(design): datatable strategy — theme jQuery DataTables via a Tail…
Jul 10, 2026
20d29ed
feat(cases): migrate the cases index onto the shell + DataTables skin
Jul 10, 2026
94f7dfb
refactor(cases): rebuild the cases index as a bespoke table (retire D…
Jul 10, 2026
cb7a76c
fix(cases): refine the bespoke table — filter chevrons, row action, t…
Jul 10, 2026
6152877
fix(cases): label the row Edit action (icon + text)
Jul 10, 2026
0968d76
feat(cases): replace dead hearing/judge columns with "Next court date"
Jul 10, 2026
1f65a73
fix(cases): make the row Edit action a tertiary (ghost) button
Jul 10, 2026
3eec6bd
feat(cases): collapse secondary header actions into a "More" menu
Jul 13, 2026
52ca1c1
feat(health): rebuild the metrics page as accessible server-rendered …
Jul 13, 2026
2f66b3a
feat(health): add a date-range filter and a Stimulus hover layer
Jul 13, 2026
660473f
chore(deps): drop unused chart.js, chartjs-adapter-luxon, and luxon
Jul 13, 2026
ddca724
fix(shell): open only one header dropdown at a time
Jul 13, 2026
8fca6d3
feat(cases): sortable column headers
Jul 13, 2026
f87fc55
feat(cases): free-text search on the cases index
Jul 13, 2026
24e19c9
feat(cases): responsive cases index (mobile cards + 2-up filters)
Jul 13, 2026
fad6e19
feat(health): responsive metrics page
Jul 13, 2026
05ed2cf
feat(dashboards): responsive supervisor + volunteer tables
Jul 14, 2026
e53aded
Fix WCAG AA contrast on the swept pages (notifications, edit profile,…
Jul 14, 2026
55396c8
Record the responsive sweep and true-width verification in the design…
Jul 14, 2026
c952c86
Add bin/measure-responsive.mjs, the true-width overflow checker
Jul 14, 2026
f7175b2
Harden shipped work: fix occurred_at flake, cover SupervisorDashboard
Jul 14, 2026
0d3087c
Case show infra: add-to-calendar and modal Stimulus controllers
Jul 14, 2026
a5642e1
Case show infra: Tailwind case-contact card + medium_icon decorator
Jul 14, 2026
bc8db2a
Case show infra: court-report and local-storage-reset controllers
Jul 14, 2026
7cea364
Migrate Case show to the Tailwind casa_app layout (Phase B)
Jul 14, 2026
21aeade
Fix WCAG AA contrast on the Resolve Reminder button
Jul 14, 2026
a6a61e6
Docs: record the Case show migration + native-dialog modal pattern
Jul 14, 2026
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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ Hotwire-first: Turbo for navigation/forms, Stimulus controllers in `app/javascri
## Style
This project uses **Standard.rb** (not vanilla RuboCop). Don't fight it on spacing/quotes/trailing commas. There is also a `.standard_todo.yml` of grandfathered violations — leave older files alone unless touching them substantively. Older migration files (2020–2024) are intentionally excluded from linting.

## Design system (casadesign branch)

This branch introduces a Tailwind CSS v4 design system, documented in **`design.md`** (repo root) — the source of truth for the new aesthetic. Read it before UI work and keep it updated. New pages use Tailwind-only layouts and coexist with the legacy Bootstrap UI (migrate page-by-page). **On every page, show user names without honorific prefixes (first + last only) — `display_person` for new UI, `formatted_name` at existing `.display_name` sites — never mutate the stored `display_name`.** **Workflow: on `casadesign`, commit and push to the `casadesign` branch at every checkpoint.**

## Where to look for more
- `.github/instructions/ruby.instructions.md` and `.github/instructions/copilot-review.instructions.md` — the project's own review checklist; the source of truth for "what is a bug-shaped change here."
- `doc/architecture-decisions/` — ADRs (especially 0002 no-UI-signup, 0003 two-user-tables, 0006 few-controller-tests, 0007 inline-email-CSS).
Expand Down
1 change: 1 addition & 0 deletions Procfile.dev
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
web: bin/rails server -p 3000 -b 0.0.0.0
js: npm run build:dev --watch
css: npm run build:css:dev --watch
tw: npm run build:tailwind:dev
27 changes: 27 additions & 0 deletions app/assets/stylesheets/tailwind.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
@import "tailwindcss";

/* Where Tailwind should look for class names (paths are relative to this file). */
@source "../../components/**/*.{erb,rb}";
@source "../../views/**/*.erb";
@source "../../helpers/**/*.rb";
@source "../../javascript/**/*.js";

/* ------------------------------------------------------------------
CASA design system (Tailwind v4 CSS-first config).
A calm, professional indigo palette + Figtree — the visual direction
for the casadesign redesign.
------------------------------------------------------------------ */
@theme {
--font-sans: "Figtree", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;

--color-brand-50: #eef2ff;
--color-brand-100: #e0e7ff;
--color-brand-200: #c7d2fe;
--color-brand-300: #a5b4fc;
--color-brand-400: #818cf8;
--color-brand-500: #6366f1;
--color-brand-600: #4f46e5;
--color-brand-700: #4338ca;
--color-brand-800: #3730a3;
--color-brand-900: #312e81;
}
33 changes: 17 additions & 16 deletions app/components/notification_component.html.erb
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
<% read = notification.read? %>
<a
href="<%= mark_as_read_notification_path(notification) %>"
class="<%= muted_display %> list-group-item list-group-item-action flex-column align-items-start"
data-method="post">
<div class="d-flex flex-row">
<div class="d-flex-shrink-0">
<% unless notification.read? %>
<i class='fas fa-bell'></i>
<% end %>
</div>
<div class="ml-2 flex-grow-1">
<div class="d-flex justify-content-between">
<h5 class="mb-1"><%= notification.event.title %></h5>
<small><%= time_ago_in_words(notification.created_at) %> ago</small>
</div>
<div class="my-1">
<%= simple_format notification.event.message %>
</div>
data-method="post"
data-notification-list-item
<%= "data-read" if read %>
class="group flex items-start gap-3.5 px-4 py-3.5 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500">
<span class="grid h-9 w-9 shrink-0 place-items-center rounded-xl <%= read ? "bg-slate-100 text-slate-400" : "bg-brand-50 text-brand-600" %>">
<i class="bi <%= read ? "bi-bell" : "bi-bell-fill" %> text-sm" aria-hidden="true"></i>
</span>
<div class="min-w-0 flex-1">
<div class="flex items-baseline justify-between gap-3">
<p class="truncate text-sm font-semibold <%= read ? "text-slate-500" : "text-slate-900" %>"><%= notification.event.title %></p>
<span class="shrink-0 text-xs text-slate-500"><%= time_ago_in_words(notification.created_at) %> ago</span>
</div>
<div class="mt-0.5 text-sm text-slate-600"><%= simple_format notification.event.message %></div>
</div>
<% unless read %>
<span data-unread-dot class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-brand-500" aria-hidden="true"></span>
<span class="sr-only">Unread</span>
<% end %>
</a>
4 changes: 0 additions & 4 deletions app/components/notification_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,4 @@ class NotificationComponent < ViewComponent::Base
def initialize(notification:)
@notification = notification
end

def muted_display
"bg-light text-muted" if notification.read?
end
end
1 change: 1 addition & 0 deletions app/controllers/all_casa_admins/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@

class AllCasaAdmins::SessionsController < Devise::SessionsController
include Accessible
layout "casa_auth"
skip_before_action :check_user, only: :destroy
end
75 changes: 70 additions & 5 deletions app/controllers/casa_cases_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,26 @@ class CasaCasesController < ApplicationController
before_action :require_organization!
after_action :verify_authorized

SORT_COLUMNS = %w[case_number next_court_date status transition assigned].freeze

def index
authorize CasaCase
org_cases = current_user.casa_org.casa_cases.includes(:assigned_volunteers, :casa_case_emancipation_categories)
@casa_cases = policy_scope(org_cases).includes([:hearing_type, :judge])
@casa_cases_filter_id = policy(CasaCase).can_see_filters? ? "casa-cases" : ""
@duties = OtherDuty.where(creator_id: current_user.id)
@active_nav = "cases"
@sort = SORT_COLUMNS.include?(params[:sort]) ? params[:sort] : "case_number"
@direction = (params[:direction] == "desc") ? "desc" : "asc"
org_cases = current_user.casa_org.casa_cases.includes(:assigned_volunteers, :court_dates)
scope = policy_scope(org_cases)
scope = filter_casa_cases(scope) if policy(CasaCase).can_see_filters?
@pagy, @casa_cases = pagy(order_casa_cases(scope))
render :index, layout: "casa_app"
end

def show
authorize @casa_case
@active_nav = "cases"

respond_to do |format|
format.html {}
format.html { render layout: "casa_app" }
format.csv do
case_contacts = @casa_case.decorate.case_contacts_ordered_by_occurred_at
csv = CaseContactsExportCsvService.new(case_contacts, CaseContactReport::COLUMNS).perform
Expand Down Expand Up @@ -140,6 +147,64 @@ def copy_court_orders

private

# Orders the cases index by the whitelisted ?sort= column and ?direction=. Derived
# columns (next court date, assigned volunteer) use correlated subqueries; a secondary
# sort by case number keeps pagination stable.
def order_casa_cases(scope)
today = ActiveRecord::Base.connection.quote(Date.current)
clause =
case @sort
when "status" then "casa_cases.active"
when "transition" then "casa_cases.birth_month_year_youth"
when "next_court_date"
"(SELECT MIN(court_dates.date) FROM court_dates WHERE court_dates.casa_case_id = casa_cases.id AND court_dates.date >= #{today})"
when "assigned"
"(SELECT MIN(users.display_name) FROM case_assignments JOIN users ON users.id = case_assignments.volunteer_id WHERE case_assignments.casa_case_id = casa_cases.id AND case_assignments.active)"
else "casa_cases.case_number"
end
scope = scope.order(Arel.sql("#{clause} #{@direction} NULLS LAST"))
scope = scope.order(case_number: :asc) unless @sort == "case_number"
scope
end

# Server-side filtering for the cases index (admins/supervisors). Params come from the
# filter bar selects; volunteers never reach this. Status defaults to active.
def filter_casa_cases(scope)
if params[:search].present?
term = "%#{ActiveRecord::Base.sanitize_sql_like(params[:search].strip)}%"
scope = scope.where(
"casa_cases.case_number ILIKE :term OR EXISTS (SELECT 1 FROM case_assignments ca " \
"JOIN users u ON u.id = ca.volunteer_id WHERE ca.casa_case_id = casa_cases.id AND ca.active " \
"AND u.display_name ILIKE :term)",
term: term
)
end

scope = case params[:status]
when "inactive" then scope.inactive
when "all" then scope
else scope.active
end

case params[:assigned]
when "assigned" then scope = scope.where(id: CaseAssignment.active.select(:casa_case_id))
when "unassigned" then scope = scope.where.not(id: CaseAssignment.active.select(:casa_case_id))
end

case params[:transition]
when "yes" then scope = scope.is_transitioned
when "no" then scope = scope.where.not(id: current_user.casa_org.casa_cases.is_transitioned.select(:id))
end

case params[:prefix]
when "CINA" then scope = scope.where("case_number ILIKE ?", "CINA%")
when "TPR" then scope = scope.where("case_number ILIKE ?", "TPR%")
when "None" then scope = scope.where.not("case_number ILIKE ? OR case_number ILIKE ?", "CINA%", "TPR%")
end

scope
end

# Use callbacks to share common setup or constraints between actions.
def set_casa_case
@casa_case = current_organization.casa_cases.friendly.find(params[:id])
Expand Down
12 changes: 9 additions & 3 deletions app/controllers/dashboard_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,17 @@ def show
if volunteer_with_only_one_active_case?
redirect_to new_case_contact_path
elsif current_user.volunteer?
redirect_to casa_cases_path
@active_nav = "dashboard"
@dashboard = VolunteerDashboard.new(current_user)
render "dashboard/volunteer", layout: "casa_app"
elsif current_user.supervisor?
redirect_to volunteers_path
@active_nav = "dashboard"
@dashboard = SupervisorDashboard.new(current_user)
render "dashboard/supervisor", layout: "casa_app"
elsif current_user.casa_admin?
redirect_to supervisors_path
@active_nav = "dashboard"
@dashboard = AdminDashboard.new(current_organization)
render "dashboard/admin", layout: "casa_app"
end
end

Expand Down
60 changes: 59 additions & 1 deletion app/controllers/health_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,16 @@ class HealthController < ApplicationController
skip_after_action :verify_policy_scoped # TODO: index should call policy_scope; remove this skip once it does
before_action :verify_token_for_old_object_stats, only: [:old_objects]

ALLOWED_RANGES = [3, 6, 12].freeze

def index
respond_to do |format|
format.html do
render :index
@range = ALLOWED_RANGES.include?(params[:range].to_i) ? params[:range].to_i : 12
@case_contacts = monthly_case_contacts(@range)
@active_users = monthly_active_users(@range)
@contact_heatmap = contact_creation_heatmap(@range)
render :index, layout: "metrics"
end

format.json { render json: {latest_deploy_time: Health.instance.latest_deploy_time} }
Expand Down Expand Up @@ -85,6 +91,58 @@ def monthly_unique_users_graph_data

private

def last_month_starts(months_back)
(0..months_back - 1).map { |i| (months_back - 1 - i).months.ago.beginning_of_month }
end

def monthly_case_contacts(months_back)
months = last_month_starts(months_back)
pick = ->(counts) { months.map { |m| counts.find { |k, _| k.year == m.year && k.month == m.month }&.last || 0 } }
total = CaseContact.group_by_month(:created_at, last: months_back).count
with_notes = CaseContact.where.not(notes: [nil, ""]).group_by_month(:created_at, last: months_back).count
loggers = CaseContact.group_by_month(:created_at, last: months_back).distinct.count(:creator_id)
{
labels: months.map { |m| m.strftime("%b") },
series: [
{name: "Total contacts", data: pick.call(total)},
{name: "With notes", data: pick.call(with_notes)},
{name: "Unique loggers", data: pick.call(loggers)}
],
distinct_loggers: CaseContact.where(created_at: months.first..).distinct.count(:creator_id)
}
end

def monthly_active_users(months_back)
months = last_month_starts(months_back)
keys = months.map { |m| m.strftime("%b %Y") }
by_type = ->(type) {
LoginActivity
.joins("INNER JOIN users ON users.id = login_activities.user_id AND login_activities.user_type = 'User'")
.where(users: {type: type}, success: true)
.group_by_month(:created_at, format: "%b %Y").distinct.count(:user_id)
}
volunteers = by_type.call("Volunteer")
supervisors = by_type.call("Supervisor")
admins = by_type.call("CasaAdmin")
logged = CaseContact.joins(supervisor_volunteer: :volunteer).group_by_month(:created_at, format: "%b %Y").distinct.count(:creator_id)
{
labels: months.map { |m| m.strftime("%b") },
series: [
{name: "Volunteers", data: keys.map { |k| volunteers[k] || 0 }},
{name: "Supervisors", data: keys.map { |k| supervisors[k] || 0 }},
{name: "Admins", data: keys.map { |k| admins[k] || 0 }},
{name: "Active loggers", data: keys.map { |k| logged[k] || 0 }}
]
}
end

def contact_creation_heatmap(months_back)
grid = CaseContact.where(created_at: months_back.months.ago.beginning_of_month..)
.group("EXTRACT(DOW FROM created_at)::int")
.group("EXTRACT(HOUR FROM created_at)::int").count
{grid: grid, max: grid.values.max || 0}
end

def each_old_object
ObjectSpace.each_object do |obj|
next unless ObjectSpace.dump(obj).include?('"old":true')
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/notifications_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class NotificationsController < ApplicationController
layout "casa_app"

after_action :verify_authorized
skip_after_action :verify_policy_scoped # TODO: index should call policy_scope; remove this skip once it does
before_action :set_notification, only: %i[mark_as_read]
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/users/invitations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ def edit
self.resource = resource_class.new
set_minimum_password_length if respond_to?(:set_minimum_password_length, true)
resource.invitation_token = params[:invitation_token]
render :edit
render :edit, layout: "casa_auth"
end
end
2 changes: 2 additions & 0 deletions app/controllers/users/passwords_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ class Users::PasswordsController < Devise::PasswordsController
include PhoneNumberHelper
include SmsBodyHelper

layout "casa_auth"

def create
@email = params.dig(resource_name, :email)
@phone_number = params.dig(resource_name, :phone_number)
Expand Down
1 change: 1 addition & 0 deletions app/controllers/users/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@

class Users::SessionsController < Devise::SessionsController
include Accessible
layout "casa_auth"
skip_before_action :check_user, only: :destroy
end
2 changes: 2 additions & 0 deletions app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class UsersController < ApplicationController
layout "casa_app"

before_action :get_user
before_action :authorize_user_with_policy
before_action :set_active_casa_admins
Expand Down
2 changes: 1 addition & 1 deletion app/datatables/case_contact_datatable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def data
medium_type: case_contact.medium_type&.titleize,
creator: {
id: case_contact.creator_id,
display_name: case_contact.creator&.display_name,
display_name: NamePresentation.strip_honorific(case_contact.creator&.display_name),
email: case_contact.creator&.email,
role: case_contact.creator&.role
},
Expand Down
2 changes: 1 addition & 1 deletion app/datatables/reimbursement_datatable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def data
occurred_at: case_contact.occurred_at,
volunteer: {
address: case_contact.creator.address&.content,
display_name: case_contact.creator.display_name,
display_name: NamePresentation.strip_honorific(case_contact.creator.display_name),
email: case_contact.creator.email,
id: case_contact.creator.id
}
Expand Down
2 changes: 1 addition & 1 deletion app/datatables/supervisor_datatable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def data
{
id: supervisor.id,
active: supervisor.active?,
display_name: supervisor.display_name,
display_name: NamePresentation.strip_honorific(supervisor.display_name),
email: supervisor.email,
volunteer_assignments: supervisor.volunteers.count,
transitions_volunteers: supervisor.volunteers_serving_transition_aged_youth,
Expand Down
4 changes: 2 additions & 2 deletions app/datatables/volunteer_datatable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def data
active: volunteer.active?,
casa_cases: volunteer.casa_cases.map { |cc| {id: cc.id, case_number: cc.case_number} },
contacts_made_in_past_days: volunteer.contacts_made_in_past_days,
display_name: volunteer.display_name,
display_name: NamePresentation.strip_honorific(volunteer.display_name),
email: volunteer.email,
has_transition_aged_youth_cases: volunteer.has_transition_aged_youth_cases?,
id: volunteer.id,
Expand All @@ -27,7 +27,7 @@ def data
case_id: volunteer.most_recent_attempt_case_id,
occurred_at: I18n.l(volunteer.most_recent_attempt_occurred_at, format: :full, default: nil)
},
supervisor: {id: volunteer.supervisor_id, name: volunteer.supervisor_name},
supervisor: {id: volunteer.supervisor_id, name: NamePresentation.strip_honorific(volunteer.supervisor_name)},
hours_spent_in_days: volunteer.hours_spent_in_days(30),
extra_languages: volunteer.languages&.map { |lang| {id: lang.id, name: lang.name} }
}
Expand Down
6 changes: 6 additions & 0 deletions app/decorators/casa_case_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ def formatted_updated_at
I18n.l(object.updated_at, format: :standard, default: nil)
end

def formatted_next_court_date
upcoming = object.court_dates.select { |court_date| court_date.date.to_date >= Date.current }.min_by(&:date)
return nil unless upcoming
I18n.l(upcoming.date, format: :full, default: nil)
end

def inactive_class
(!object.active) ? "table-secondary" : ""
end
Expand Down
Loading
Loading