Tweak lifecycle to make more stateless, fix some styling

This commit is contained in:
Philip Peterson 2026-05-27 21:58:19 -07:00
parent 5ea7e69f5a
commit cd2d59f298
10 changed files with 497 additions and 427 deletions

View file

@ -7,12 +7,39 @@ A Drupal 11 site for Riverside Physical Therapy. Nearly all frontend work lives
## Running locally
```bash
docker compose up # starts app on http://localhost:8080
docker compose up # starts app on http://localhost:8080 (full DB wipe + rebuild from code by default)
docker compose exec app drush cr # clear Drupal cache
npm run watch # Tailwind CSS watcher (run on host, not in container)
npm run build # minified production build
```
### Database & site rebuild behavior
By default, **every** `docker compose up` performs a full database wipe followed by a complete reinstall + rebuild of the site structure from code:
- Drops the database
- Runs `drush site:install standard`
- Enables modules (including `riverside_pt`)
- Runs `drush riverside:rebuild` (the single source of truth for content types, fields, roles, and navigation)
This means the site is **always** built exactly the same way from the code in `riverside_pt.install` and the Drush command. There is no persistent data between restarts unless you opt out.
**Faster iteration (preserve the database):**
```bash
DRUPAL_FAST=1 docker compose up
```
This skips the wipe + `site:install` but still runs `drush riverside:rebuild` and the rest of the startup steps. Use this when you want quicker starts during active development and don't need a completely clean slate.
You can also run the rebuild manually at any time:
```bash
docker compose exec app drush riverside:rebuild
# or the short alias
docker compose exec app drush rrb
```
The custom module directory is volume-mounted, so template/CSS/JS edits are live without rebuilding the Docker image. `settings.php` and `development.services.yml` are also volume-mounted.
## Stack

View file

@ -8,6 +8,8 @@ A Drupal-based appointment scheduling site for booking sessions between patients
docker compose up --build
```
**Default behavior**: Every start performs a full database wipe + rebuilds the entire site from code (content types, fields, menu, etc.). See CLAUDE.md for details and the `DRUPAL_FAST=1` escape hatch for faster iteration.
Admin login: `admin` / `admin` at `/user/login`
## Makefile commands

View file

@ -16,6 +16,10 @@ services:
DEBUG: "${DEBUG:-true}"
POSTMARK_API_KEY: "${POSTMARK_API_KEY:?POSTMARK_API_KEY is required}"
BASE_URL: "${BASE_URL:-http://localhost:8080}"
# DRUPAL_FAST=1 skips the automatic full database wipe + reinstall on every start.
# Use this for faster iteration when you don't want a completely fresh site.
# Default (no flag) = always wipe DB and rebuild everything from code.
DRUPAL_FAST: "${DRUPAL_FAST:-}"
volumes:
- ./web/sites/default/files:/var/www/html/web/sites/default/files
- ./web/sites/default/settings.php:/var/www/html/web/sites/default/settings.php

View file

@ -22,12 +22,15 @@ cd /var/www/html
DRUSH="vendor/bin/drush --root=/var/www/html/web"
HAS_TABLES=$($DRUSH sql:query \
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='users';" \
2>/dev/null || echo "0")
echo "[entrypoint] Preparing database..."
if [ "$HAS_TABLES" != "1" ]; then
echo "[entrypoint] Fresh database, installing Drupal..."
if [ "${DRUPAL_FAST:-}" = "1" ]; then
echo "[entrypoint] DRUPAL_FAST=1 — skipping database wipe and full site reinstall."
else
echo "[entrypoint] Full rebuild mode (default). Dropping database..."
$DRUSH sql:drop -y || true
echo "[entrypoint] Installing Drupal (standard profile)..."
$DRUSH site:install standard \
--site-name="$SITE_NAME" \
--account-name=admin \
@ -46,19 +49,18 @@ $DRUSH en -y symfony_mailer && \
$DRUSH en -y riverside_pt && \
echo "[entrypoint] riverside_pt enabled." || echo "[entrypoint] WARNING: riverside_pt failed."
echo "[entrypoint] Rebuilding site structure from code (riverside:rebuild)..."
$DRUSH riverside:rebuild || echo "[entrypoint] WARNING: riverside:rebuild encountered an issue."
# Re-assert a few key pieces (cheap and safe).
$DRUSH theme:enable starterkit_theme claro_compact -y && \
$DRUSH config:set system.theme default starterkit_theme -y && \
$DRUSH config:set system.theme admin claro_compact -y && \
echo "[entrypoint] Themes set." || echo "[entrypoint] WARNING: theme enable failed."
$DRUSH config:set system.site page.front /home -y && \
echo "[entrypoint] Front page set." || echo "[entrypoint] WARNING: front page set failed."
if ls /var/www/html/config/sync/*.yml >/dev/null 2>&1; then
echo "[entrypoint] Importing configuration..."
$DRUSH config:import -y && \
echo "[entrypoint] Config imported." || echo "[entrypoint] WARNING: config import failed."
fi
npm run build --prefix /var/www/html >/dev/null 2>&1 && echo "[entrypoint] Tailwind built." || echo "[entrypoint] WARNING: Tailwind build failed."
$DRUSH cache:rebuild >/dev/null 2>&1 && echo "[entrypoint] Cache rebuilt."

View file

@ -10,6 +10,9 @@ module.exports = {
screens: {
'md': '920px',
},
fontFamily: {
hedvig: ['Hedvig Letters Sans', 'sans-serif'],
},
},
},
plugins: [],

View file

@ -1,3 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Hedvig+Letters+Sans:wght@400;500;600&display=swap');
*, ::before, ::after{
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
@ -639,10 +641,6 @@ video {
position: static;
}
.fixed {
position: fixed;
}
.absolute{
position: absolute;
}
@ -667,18 +665,6 @@ video {
top: 50%;
}
.right-4 {
right: 1rem;
}
.top-4 {
top: 1rem;
}
.z-50 {
z-index: 50;
}
.m-0{
margin: 0px;
}
@ -724,6 +710,10 @@ video {
margin-top: 0.5rem;
}
.mb-2{
margin-bottom: 0.5rem;
}
.block{
display: block;
}
@ -776,10 +766,6 @@ video {
height: 420px;
}
.h-8 {
height: 2rem;
}
.max-h-\[calc\(100\%_-_100px\)\]{
max-height: calc(100% - 100px);
}
@ -808,10 +794,6 @@ video {
width: 100%;
}
.w-8 {
width: 2rem;
}
.min-w-0{
min-width: 0px;
}
@ -1048,10 +1030,6 @@ video {
background-color: rgb(255 255 255 / 0.9);
}
.bg-black\/50 {
background-color: rgb(0 0 0 / 0.5);
}
.bg-gradient-to-t{
background-image: linear-gradient(to top, var(--tw-gradient-stops));
}
@ -1097,10 +1075,6 @@ video {
padding: 1.5rem;
}
.p-4 {
padding: 1rem;
}
.px-2{
padding-left: 0.5rem;
padding-right: 0.5rem;
@ -1171,16 +1145,6 @@ video {
padding-bottom: 1em;
}
.px-8 {
padding-left: 2rem;
padding-right: 2rem;
}
.py-6 {
padding-top: 1.5rem;
padding-bottom: 1.5rem;
}
.pb-4{
padding-bottom: 1rem;
}
@ -1205,6 +1169,10 @@ video {
font-family: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
}
.font-hedvig{
font-family: Hedvig Letters Sans, sans-serif;
}
.text-2xl{
font-size: 1.5rem;
line-height: 2rem;
@ -1271,9 +1239,8 @@ video {
line-height: 1rem;
}
.text-xl {
font-size: 1.25rem;
line-height: 1.75rem;
.text-\[2\.25rem\]{
font-size: 2.25rem;
}
.font-bold{
@ -1375,10 +1342,6 @@ video {
color: rgb(255 255 255 / 0.8);
}
.text-white\/70 {
color: rgb(255 255 255 / 0.7);
}
.no-underline{
text-decoration-line: none;
}
@ -1395,12 +1358,6 @@ video {
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.shadow-2xl {
--tw-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.outline-none{
outline: 2px solid transparent;
outline-offset: 2px;
@ -1482,11 +1439,6 @@ video {
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
}
.hover\:text-gray-600:hover {
--tw-text-opacity: 1;
color: rgb(75 85 99 / var(--tw-text-opacity, 1));
}
.disabled\:opacity-30:disabled{
opacity: 0.3;
}

View file

@ -1,3 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Hedvig+Letters+Sans:wght@400;500;600&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -8,8 +8,32 @@ use Drupal\user\Entity\Role;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\field\Entity\FieldConfig;
/**
* Implements hook_install().
*
* Delegates to the rebuild function so that normal module installs and
* container rebuilds both produce the same result.
*/
function riverside_pt_install() {
// Pass 1: types, roles, and field storages (no inter-dependencies).
_riverside_pt_rebuild();
}
/**
* Rebuilds the Riverside PT site structure from code.
*
* This function is idempotent and can be safely called multiple times.
* It is the single source of truth for content types, fields, roles,
* and navigation used by the Docker entrypoint.
*/
function _riverside_pt_rebuild(): void {
$entity_type_manager = \Drupal::entityTypeManager();
$storage_node_type = $entity_type_manager->getStorage('node_type');
$storage_role = $entity_type_manager->getStorage('user_role');
$storage_field_storage = $entity_type_manager->getStorage('field_storage_config');
$storage_field_config = $entity_type_manager->getStorage('field_config');
// --- Content types (idempotent) ---
if (!$storage_node_type->load('appointment')) {
NodeType::create([
'type' => 'appointment',
'name' => 'Appointment',
@ -17,7 +41,9 @@ function riverside_pt_install() {
'new_revision' => FALSE,
'display_submitted' => FALSE,
])->save();
}
if (!$storage_node_type->load('provider_availability')) {
NodeType::create([
'type' => 'provider_availability',
'name' => 'Provider Availability',
@ -25,24 +51,25 @@ function riverside_pt_install() {
'new_revision' => FALSE,
'display_submitted' => FALSE,
])->save();
}
// --- Role (idempotent) ---
if (!$storage_role->load('provider')) {
Role::create(['id' => 'provider', 'label' => 'Provider'])->save();
}
FieldStorageConfig::create([
'field_name' => 'field_appointment_date',
// --- Field storages (idempotent) ---
$field_storages = [
'field_appointment_date' => [
'entity_type' => 'node',
'type' => 'datetime',
'settings' => ['datetime_type' => 'datetime'],
])->save();
FieldStorageConfig::create([
'field_name' => 'field_duration_minutes',
],
'field_duration_minutes' => [
'entity_type' => 'node',
'type' => 'integer',
])->save();
FieldStorageConfig::create([
'field_name' => 'field_service_type',
],
'field_service_type' => [
'entity_type' => 'node',
'type' => 'list_string',
'settings' => [
@ -53,58 +80,60 @@ function riverside_pt_install() {
'neurological_pt' => 'Neurological PT',
],
],
])->save();
FieldStorageConfig::create([
'field_name' => 'field_provider',
],
'field_provider' => [
'entity_type' => 'node',
'type' => 'entity_reference',
'settings' => ['target_type' => 'user'],
])->save();
FieldStorageConfig::create([
'field_name' => 'field_start_datetime',
],
'field_start_datetime' => [
'entity_type' => 'node',
'type' => 'datetime',
'settings' => ['datetime_type' => 'datetime'],
])->save();
FieldStorageConfig::create([
'field_name' => 'field_end_datetime',
],
'field_end_datetime' => [
'entity_type' => 'node',
'type' => 'datetime',
'settings' => ['datetime_type' => 'datetime'],
])->save();
],
];
// Clear field definition cache so FieldConfig::preSave() can find the storages.
foreach ($field_storages as $field_name => $definition) {
if (!$storage_field_storage->load("node.$field_name")) {
FieldStorageConfig::create([
'field_name' => $field_name,
] + $definition)->save();
}
}
// Clear field definition cache so FieldConfig creation can see the storages.
\Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
// Pass 2: field configs (depend on storages being in the DB).
FieldConfig::create([
// --- Field configs (idempotent) ---
$field_configs = [
// Appointment bundle
'node.appointment.field_appointment_date' => [
'field_name' => 'field_appointment_date',
'entity_type' => 'node',
'bundle' => 'appointment',
'label' => 'Appointment Date',
'required' => TRUE,
])->save();
FieldConfig::create([
],
'node.appointment.field_duration_minutes' => [
'field_name' => 'field_duration_minutes',
'entity_type' => 'node',
'bundle' => 'appointment',
'label' => 'Duration (Minutes)',
'required' => TRUE,
])->save();
FieldConfig::create([
],
'node.appointment.field_service_type' => [
'field_name' => 'field_service_type',
'entity_type' => 'node',
'bundle' => 'appointment',
'label' => 'Service Type',
'required' => TRUE,
])->save();
FieldConfig::create([
],
'node.appointment.field_provider' => [
'field_name' => 'field_provider',
'entity_type' => 'node',
'bundle' => 'appointment',
@ -116,9 +145,9 @@ function riverside_pt_install() {
'filter' => ['type' => '_role', 'role' => ['provider' => 'provider']],
],
],
])->save();
FieldConfig::create([
],
// Provider availability bundle
'node.provider_availability.field_provider' => [
'field_name' => 'field_provider',
'entity_type' => 'node',
'bundle' => 'provider_availability',
@ -130,24 +159,30 @@ function riverside_pt_install() {
'filter' => ['type' => '_role', 'role' => ['provider' => 'provider']],
],
],
])->save();
FieldConfig::create([
],
'node.provider_availability.field_start_datetime' => [
'field_name' => 'field_start_datetime',
'entity_type' => 'node',
'bundle' => 'provider_availability',
'label' => 'Start',
'required' => TRUE,
])->save();
FieldConfig::create([
],
'node.provider_availability.field_end_datetime' => [
'field_name' => 'field_end_datetime',
'entity_type' => 'node',
'bundle' => 'provider_availability',
'label' => 'End',
'required' => TRUE,
])->save();
],
];
foreach ($field_configs as $field_id => $definition) {
if (!$storage_field_config->load($field_id)) {
FieldConfig::create($definition)->save();
}
}
// Navigation and basic site settings (these are intentionally destructive on menu).
try {
_riverside_pt_build_navigation();
}

View file

@ -0,0 +1,43 @@
<?php
namespace Drupal\riverside_pt\Commands;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drush\Attributes as CLI;
use Drush\Commands\DrushCommands;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Drush commands for Riverside PT site management.
*/
final class RiversidePtCommands extends DrushCommands implements ContainerInjectionInterface {
public static function create(ContainerInterface $container): self {
return new self();
}
/**
* Rebuilds the entire Riverside PT site structure from code.
*
* This is the command used by the Docker entrypoint on every start
* (unless DRUPAL_FAST=1 is passed).
*/
#[CLI\Command(name: 'riverside:rebuild', aliases: ['rrb'])]
#[CLI\Usage(name: 'drush riverside:rebuild', description: 'Rebuild content types, fields, roles, and navigation from code. Safe to run repeatedly.')]
public function rebuild(): void {
$this->output()->writeln('<info>Rebuilding Riverside PT site structure from code...</info>');
// Make the helper functions from riverside_pt.install available.
\Drupal::moduleHandler()->loadInclude('riverside_pt', 'install');
if (!function_exists('_riverside_pt_rebuild')) {
$this->logger()->error('Could not load _riverside_pt_rebuild().');
return;
}
_riverside_pt_rebuild();
$this->output()->writeln('<info>Rebuild complete.</info>');
$this->logger()->success('Riverside PT structure has been rebuilt from code.');
}
}

View file

@ -46,8 +46,8 @@
<section class="py-16 px-6 bg-white">
<div class="max-w-[1040px] mx-auto mb-12">
<p class="text-xs tracking-widest uppercase text-[#306f8e] font-semibold text-center mb-3">Bringing Relief</p>
<h2 class="text-[2.75rem] font-serif font-normal text-gray-900 leading-tight">Our Wide Range of Physical Therapy Services</h2>
<p class="text-sm tracking-widest uppercase text-[#306f8e] font-semibold text-center mb-2">Bringing Relief</p>
<h2 class="text-[2.25rem] font-serif font-normal text-gray-900 leading-tight">Our Wide Range of Physical Therapy Services</h2>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-6 max-w-[1040px] mx-auto">
<div class="flex flex-col border border-[#b8d4dc] bg-white overflow-hidden">
@ -96,8 +96,8 @@
<p class="text-[4.5rem] font-serif text-[#306f8e] leading-none">15</p>
<p class="text-xs tracking-widest uppercase text-[#306f8e] font-semibold mt-2">Years Open</p>
</div>
<div>
<p class="text-[4.5rem] font-serif text-[#306f8e] leading-none">300</p>
<div class="font-hedvig">
<p class="text-[4.5rem] text-[#306f8e] leading-none">300</p>
<p class="text-xs tracking-widest uppercase text-[#306f8e] font-semibold mt-2">Patients Served</p>
</div>
</div>