瀏覽代碼

Phase 1: skeleton

Dockerfile (php:8.3-apache + pdo_sqlite), docker-compose, Composer manifest
with jumbojett/openid-connect-php + vlucas/phpdotenv. Front controller in
public/index.php with .htaccess rewrite, loads env, runs the migrator on
every request (idempotent), dispatches via a tiny Router.

Supporting pieces:
- src/Db/Connection.php — PDO/SQLite with foreign_keys=ON, WAL, 5s busy timeout
- src/Db/Migrator.php — schema_version table + NNN_*.sql runner, one tx per file
- src/Http/{Request,Response,Router,View}.php — no-framework plumbing
- views/{layout,home}.php — Tailwind/jQuery CDN layout + hello page

Verified: php -l across all files, end-to-end Connection+Migrator smoke test
(creates schema_version, foreign_keys=1), Router dispatch test covering
params, 404, and 405.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
achiappa 2 周之前
當前提交
58a6b3013e
共有 20 個文件被更改,包括 819 次插入0 次删除
  1. 22 0
      .env.example
  2. 10 0
      .gitignore
  3. 24 0
      Dockerfile
  4. 40 0
      README.md
  5. 0 0
      assets/css/.gitkeep
  6. 0 0
      assets/js/.gitkeep
  7. 34 0
      composer.json
  8. 9 0
      docker-compose.yml
  9. 0 0
      migrations/.gitkeep
  10. 12 0
      public/.htaccess
  11. 84 0
      public/index.php
  12. 81 0
      src/Db/Connection.php
  13. 126 0
      src/Db/Migrator.php
  14. 91 0
      src/Http/Request.php
  15. 57 0
      src/Http/Response.php
  16. 86 0
      src/Http/Router.php
  17. 74 0
      src/Http/View.php
  18. 0 0
      tests/.gitkeep
  19. 38 0
      views/home.php
  20. 31 0
      views/layout.php

+ 22 - 0
.env.example

@@ -0,0 +1,22 @@
+# Entra ID / OIDC
+ENTRA_TENANT_ID=
+ENTRA_CLIENT_ID=
+ENTRA_CLIENT_SECRET=
+
+# Base URL the app is reachable at (no trailing slash).
+# Used to build the OIDC redirect URI {APP_BASE_URL}/auth/callback
+APP_BASE_URL=http://localhost:8080
+
+# Random string (>=32 bytes). Used to salt the session cookie name / CSRF tokens.
+SESSION_SECRET=
+
+# Path to the SQLite database file inside the container. Leave as-is unless
+# you have a specific reason to change it. The parent dir is the mounted
+# volume (/var/www/data).
+DB_PATH=/var/www/data/app.sqlite
+
+# Session handler files directory.
+SESSION_PATH=/var/www/data/sessions
+
+# 'production' disables verbose error output. Anything else is treated as dev.
+APP_ENV=production

+ 10 - 0
.gitignore

@@ -0,0 +1,10 @@
+/vendor/
+/data/
+/.env
+/.env.local
+composer.lock
+.DS_Store
+/var/www/data/
+*.sqlite
+*.sqlite-journal
+/node_modules/

+ 24 - 0
Dockerfile

@@ -0,0 +1,24 @@
+FROM php:8.3-apache
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+        libsqlite3-dev unzip git \
+    && docker-php-ext-install pdo pdo_sqlite \
+    && a2enmod rewrite \
+    && rm -rf /var/lib/apt/lists/*
+
+COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
+
+WORKDIR /var/www/html
+
+COPY composer.json composer.lock* ./
+RUN composer install --no-dev --no-interaction --prefer-dist --no-progress
+
+COPY . .
+
+RUN mkdir -p /var/www/data /var/www/data/sessions \
+    && chown -R www-data:www-data /var/www/data \
+    && sed -ri 's!/var/www/html!/var/www/html/public!g' /etc/apache2/sites-available/000-default.conf \
+    && sed -ri 's!/var/www/!/var/www/html/public!g'   /etc/apache2/apache2.conf
+
+EXPOSE 80
+CMD ["apache2-foreground"]

+ 40 - 0
README.md

@@ -0,0 +1,40 @@
+# Sprint Planner
+
+Web replacement for the Excel-based sprint planning tool.
+
+## Stack
+
+- PHP 8.3 + Apache (Docker, single container)
+- SQLite (file on mounted volume)
+- Server-rendered PHP templates + Tailwind CSS + jQuery / jQuery UI (all via CDN)
+- Microsoft Entra ID (OpenID Connect) for auth
+
+## Quick start
+
+```bash
+cp .env.example .env
+# fill in ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET, SESSION_SECRET
+docker compose up --build
+```
+
+Then open http://localhost:8080.
+
+The SQLite database lives at `./data/app.sqlite` (mounted into the container at
+`/var/www/data/app.sqlite`). Migrations run automatically on the first request
+after container start.
+
+## Layout
+
+```
+public/      front controller (index.php) + web root
+src/         application code (App\ namespace, PSR-4)
+views/       PHP templates
+migrations/  numbered .sql files applied by Migrator
+assets/      static JS / CSS
+data/        SQLite + sessions (volume-mounted; gitignored)
+```
+
+## Build phases
+
+This repo is built incrementally. See the spec for phase definitions. Current
+phase: **Phase 1 — skeleton**.

+ 0 - 0
assets/css/.gitkeep


+ 0 - 0
assets/js/.gitkeep


+ 34 - 0
composer.json

@@ -0,0 +1,34 @@
+{
+    "name": "sprint-planer/web",
+    "description": "Sprint planning tool replacing the Excel workbook.",
+    "type": "project",
+    "license": "proprietary",
+    "require": {
+        "php": "^8.3",
+        "ext-pdo": "*",
+        "ext-pdo_sqlite": "*",
+        "ext-json": "*",
+        "jumbojett/openid-connect-php": "^1.0",
+        "vlucas/phpdotenv": "^5.6"
+    },
+    "require-dev": {
+        "phpunit/phpunit": "^11.0"
+    },
+    "autoload": {
+        "psr-4": {
+            "App\\": "src/"
+        }
+    },
+    "autoload-dev": {
+        "psr-4": {
+            "App\\Tests\\": "tests/"
+        }
+    },
+    "config": {
+        "sort-packages": true,
+        "optimize-autoloader": true
+    },
+    "scripts": {
+        "test": "phpunit --colors=always"
+    }
+}

+ 9 - 0
docker-compose.yml

@@ -0,0 +1,9 @@
+services:
+  app:
+    build: .
+    ports:
+      - "8080:80"
+    env_file: .env
+    volumes:
+      - ./data:/var/www/data
+    restart: unless-stopped

+ 0 - 0
migrations/.gitkeep


+ 12 - 0
public/.htaccess

@@ -0,0 +1,12 @@
+# Route anything that isn't a real file or directory to the front controller.
+<IfModule mod_rewrite.c>
+    RewriteEngine On
+    RewriteCond %{REQUEST_FILENAME} !-f
+    RewriteCond %{REQUEST_FILENAME} !-d
+    RewriteRule ^ index.php [QSA,L]
+</IfModule>
+
+# Don't leak the .env or composer files if someone mis-configures the docroot.
+<FilesMatch "^(\.env|composer\.(json|lock))$">
+    Require all denied
+</FilesMatch>

+ 84 - 0
public/index.php

@@ -0,0 +1,84 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Db\Connection;
+use App\Db\Migrator;
+use App\Http\Request;
+use App\Http\Response;
+use App\Http\Router;
+use App\Http\View;
+
+define('APP_ROOT', dirname(__DIR__));
+
+// ---------------------------------------------------------------------------
+// Autoload
+// ---------------------------------------------------------------------------
+$autoload = APP_ROOT . '/vendor/autoload.php';
+if (!is_file($autoload)) {
+    http_response_code(500);
+    header('Content-Type: text/plain; charset=utf-8');
+    echo "Composer dependencies are not installed.\n";
+    echo "Run: composer install (or rebuild the container).\n";
+    exit;
+}
+require $autoload;
+
+// ---------------------------------------------------------------------------
+// Environment
+// ---------------------------------------------------------------------------
+if (is_file(APP_ROOT . '/.env')) {
+    $dotenv = Dotenv\Dotenv::createImmutable(APP_ROOT);
+    $dotenv->safeLoad();
+}
+
+$appEnv = getenv('APP_ENV') ?: 'production';
+if ($appEnv !== 'production') {
+    ini_set('display_errors', '1');
+    error_reporting(E_ALL);
+} else {
+    ini_set('display_errors', '0');
+}
+
+// ---------------------------------------------------------------------------
+// Migrations — cheap no-op when already current
+// ---------------------------------------------------------------------------
+try {
+    $pdo = Connection::pdo();
+    (new Migrator($pdo))->migrate();
+} catch (\Throwable $e) {
+    http_response_code(500);
+    header('Content-Type: text/plain; charset=utf-8');
+    echo "Database bootstrap failed.\n";
+    if ($appEnv !== 'production') {
+        echo $e->getMessage() . "\n";
+    }
+    exit;
+}
+
+// ---------------------------------------------------------------------------
+// Routing
+// ---------------------------------------------------------------------------
+$view   = new View(APP_ROOT . '/views');
+$router = new Router();
+
+$router->get('/', function (Request $req) use ($view, $pdo): Response {
+    $version = (int) $pdo->query('SELECT COALESCE(MAX(version), 0) FROM schema_version')->fetchColumn();
+    return Response::html($view->render('home', [
+        'title'         => 'Sprint Planner',
+        'schemaVersion' => $version,
+        'dbPath'        => Connection::path(),
+        'appEnv'        => $appEnv,
+    ]));
+});
+
+$router->get('/healthz', function (): Response {
+    return Response::text('ok');
+});
+
+// ---------------------------------------------------------------------------
+// Dispatch
+// ---------------------------------------------------------------------------
+$request  = Request::fromGlobals();
+$response = $router->dispatch($request);
+$response->send();

+ 81 - 0
src/Db/Connection.php

@@ -0,0 +1,81 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Db;
+
+use PDO;
+use PDOException;
+use RuntimeException;
+
+/**
+ * Process-wide PDO/SQLite connection.
+ *
+ * Intentionally a small singleton: the app runs one request per PHP process
+ * under Apache's prefork MPM, so a per-process cache is safe and cheap.
+ */
+final class Connection
+{
+    private static ?PDO $pdo = null;
+    private static ?string $path = null;
+
+    public static function pdo(): PDO
+    {
+        if (self::$pdo instanceof PDO) {
+            return self::$pdo;
+        }
+
+        $path = self::resolvePath();
+        $dir = dirname($path);
+        if (!is_dir($dir)) {
+            if (!mkdir($dir, 0o775, true) && !is_dir($dir)) {
+                throw new RuntimeException("Cannot create sqlite dir: {$dir}");
+            }
+        }
+
+        try {
+            $pdo = new PDO('sqlite:' . $path, null, null, [
+                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+                PDO::ATTR_EMULATE_PREPARES => false,
+            ]);
+        } catch (PDOException $e) {
+            throw new RuntimeException("Cannot open sqlite database at {$path}: " . $e->getMessage(), 0, $e);
+        }
+
+        // Sane pragmas. foreign_keys MUST be on for every connection.
+        $pdo->exec('PRAGMA foreign_keys = ON');
+        $pdo->exec('PRAGMA journal_mode = WAL');
+        $pdo->exec('PRAGMA synchronous = NORMAL');
+        $pdo->exec('PRAGMA busy_timeout = 5000');
+
+        self::$pdo = $pdo;
+        self::$path = $path;
+        return $pdo;
+    }
+
+    public static function path(): string
+    {
+        if (self::$path === null) {
+            self::pdo();
+        }
+        return (string) self::$path;
+    }
+
+    /** Reset — only for tests. */
+    public static function reset(): void
+    {
+        self::$pdo = null;
+        self::$path = null;
+    }
+
+    private static function resolvePath(): string
+    {
+        $path = getenv('DB_PATH');
+        if (is_string($path) && $path !== '') {
+            return $path;
+        }
+        // Fallback suitable for local dev outside docker.
+        return dirname(__DIR__, 2) . '/data/app.sqlite';
+    }
+}

+ 126 - 0
src/Db/Migrator.php

@@ -0,0 +1,126 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Db;
+
+use PDO;
+use RuntimeException;
+
+/**
+ * Dirt-simple forward-only migration runner.
+ *
+ * - Scans `migrations/` for files matching `NNN_*.sql` (3+ digit prefix).
+ * - Records applied versions in `schema_version(version, filename, applied_at)`.
+ * - Each file is executed in a single transaction. No down-migrations.
+ *
+ * Safe to call on every request; becomes a cheap no-op once current.
+ */
+final class Migrator
+{
+    private PDO $pdo;
+    private string $dir;
+
+    public function __construct(PDO $pdo, ?string $migrationsDir = null)
+    {
+        $this->pdo = $pdo;
+        $this->dir = $migrationsDir ?? dirname(__DIR__, 2) . '/migrations';
+    }
+
+    /** Run all pending migrations. Returns the list of files applied this call. */
+    public function migrate(): array
+    {
+        $this->ensureVersionTable();
+
+        $applied = $this->appliedVersions();
+        $files = $this->discover();
+
+        $ran = [];
+        foreach ($files as [$version, $filename, $fullPath]) {
+            if (isset($applied[$version])) {
+                continue;
+            }
+            $this->apply($version, $filename, $fullPath);
+            $ran[] = $filename;
+        }
+        return $ran;
+    }
+
+    public function currentVersion(): int
+    {
+        $this->ensureVersionTable();
+        $stmt = $this->pdo->query('SELECT COALESCE(MAX(version), 0) AS v FROM schema_version');
+        $row = $stmt->fetch();
+        return (int) ($row['v'] ?? 0);
+    }
+
+    private function ensureVersionTable(): void
+    {
+        $this->pdo->exec(<<<SQL
+            CREATE TABLE IF NOT EXISTS schema_version (
+                version    INTEGER PRIMARY KEY,
+                filename   TEXT NOT NULL,
+                applied_at TEXT NOT NULL
+            )
+        SQL);
+    }
+
+    /** @return array<int,true> version => true */
+    private function appliedVersions(): array
+    {
+        $out = [];
+        $stmt = $this->pdo->query('SELECT version FROM schema_version');
+        foreach ($stmt as $row) {
+            $out[(int) $row['version']] = true;
+        }
+        return $out;
+    }
+
+    /**
+     * @return list<array{0:int,1:string,2:string}>  [version, filename, fullPath] sorted by version asc
+     */
+    private function discover(): array
+    {
+        if (!is_dir($this->dir)) {
+            return [];
+        }
+
+        $entries = scandir($this->dir) ?: [];
+        $out = [];
+        foreach ($entries as $name) {
+            if (!preg_match('/^(\d{3,})_[A-Za-z0-9_\-]+\.sql$/', $name, $m)) {
+                continue;
+            }
+            $version = (int) $m[1];
+            $out[] = [$version, $name, $this->dir . DIRECTORY_SEPARATOR . $name];
+        }
+        usort($out, static fn($a, $b) => $a[0] <=> $b[0]);
+        return $out;
+    }
+
+    private function apply(int $version, string $filename, string $fullPath): void
+    {
+        $sql = file_get_contents($fullPath);
+        if ($sql === false) {
+            throw new RuntimeException("Cannot read migration: {$fullPath}");
+        }
+
+        $this->pdo->beginTransaction();
+        try {
+            // PDO::exec accepts multiple statements separated by ';' on sqlite.
+            $this->pdo->exec($sql);
+            $stmt = $this->pdo->prepare(
+                'INSERT INTO schema_version (version, filename, applied_at) VALUES (?, ?, ?)'
+            );
+            $stmt->execute([$version, $filename, gmdate('Y-m-d\TH:i:s\Z')]);
+            $this->pdo->commit();
+        } catch (\Throwable $e) {
+            $this->pdo->rollBack();
+            throw new RuntimeException(
+                "Migration failed ({$filename}): " . $e->getMessage(),
+                0,
+                $e
+            );
+        }
+    }
+}

+ 91 - 0
src/Http/Request.php

@@ -0,0 +1,91 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http;
+
+final class Request
+{
+    /**
+     * @param array<string,string> $query
+     * @param array<string,mixed>  $post
+     * @param array<string,string> $headers  header names already lower-cased
+     * @param array<string,string> $server
+     */
+    public function __construct(
+        public readonly string $method,
+        public readonly string $path,
+        public readonly array  $query,
+        public readonly array  $post,
+        public readonly string $rawBody,
+        public readonly array  $headers,
+        public readonly array  $server,
+    ) {
+    }
+
+    public static function fromGlobals(): self
+    {
+        $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
+        $uri    = (string) ($_SERVER['REQUEST_URI'] ?? '/');
+        $path   = parse_url($uri, PHP_URL_PATH) ?: '/';
+        if ($path !== '/' && str_ends_with($path, '/')) {
+            $path = rtrim($path, '/');
+        }
+
+        $headers = [];
+        foreach ($_SERVER as $k => $v) {
+            if (str_starts_with($k, 'HTTP_')) {
+                $name = strtolower(str_replace('_', '-', substr($k, 5)));
+                $headers[$name] = (string) $v;
+            }
+        }
+        if (isset($_SERVER['CONTENT_TYPE']))   { $headers['content-type']   = (string) $_SERVER['CONTENT_TYPE']; }
+        if (isset($_SERVER['CONTENT_LENGTH'])) { $headers['content-length'] = (string) $_SERVER['CONTENT_LENGTH']; }
+
+        $raw = (string) (file_get_contents('php://input') ?: '');
+
+        return new self(
+            method:  $method,
+            path:    $path,
+            query:   array_map(strval(...), $_GET),
+            post:    $_POST,
+            rawBody: $raw,
+            headers: $headers,
+            server:  array_map(strval(...), $_SERVER),
+        );
+    }
+
+    public function header(string $name): ?string
+    {
+        return $this->headers[strtolower($name)] ?? null;
+    }
+
+    /** Parsed JSON body; null if body is empty or not JSON. */
+    public function json(): ?array
+    {
+        if ($this->rawBody === '') {
+            return null;
+        }
+        $ct = $this->header('content-type') ?? '';
+        if (!str_contains($ct, 'json')) {
+            return null;
+        }
+        try {
+            /** @var mixed $data */
+            $data = json_decode($this->rawBody, true, 64, JSON_THROW_ON_ERROR);
+        } catch (\JsonException) {
+            return null;
+        }
+        return is_array($data) ? $data : null;
+    }
+
+    public function ip(): string
+    {
+        return (string) ($this->server['REMOTE_ADDR'] ?? '');
+    }
+
+    public function userAgent(): string
+    {
+        return $this->header('user-agent') ?? '';
+    }
+}

+ 57 - 0
src/Http/Response.php

@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http;
+
+final class Response
+{
+    /** @param array<string,string> $headers */
+    public function __construct(
+        public int    $status = 200,
+        public string $body = '',
+        public array  $headers = [],
+    ) {
+    }
+
+    public static function html(string $html, int $status = 200): self
+    {
+        return new self($status, $html, ['Content-Type' => 'text/html; charset=utf-8']);
+    }
+
+    public static function json(mixed $data, int $status = 200): self
+    {
+        $body = json_encode(
+            $data,
+            JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
+        );
+        return new self($status, $body, ['Content-Type' => 'application/json; charset=utf-8']);
+    }
+
+    public static function redirect(string $location, int $status = 302): self
+    {
+        return new self($status, '', ['Location' => $location]);
+    }
+
+    public static function text(string $text, int $status = 200): self
+    {
+        return new self($status, $text, ['Content-Type' => 'text/plain; charset=utf-8']);
+    }
+
+    public function withHeader(string $name, string $value): self
+    {
+        $this->headers[$name] = $value;
+        return $this;
+    }
+
+    public function send(): void
+    {
+        if (!headers_sent()) {
+            http_response_code($this->status);
+            foreach ($this->headers as $name => $value) {
+                header($name . ': ' . $value, true);
+            }
+        }
+        echo $this->body;
+    }
+}

+ 86 - 0
src/Http/Router.php

@@ -0,0 +1,86 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http;
+
+/**
+ * Tiny pattern router.
+ *
+ * Patterns use `{name}` for path segments. Numeric-looking captures are cast
+ * to int; everything else stays a string. Handlers receive (Request, array $params).
+ */
+final class Router
+{
+    /** @var list<array{method:string,regex:string,names:list<string>,handler:callable}> */
+    private array $routes = [];
+
+    public function add(string $method, string $pattern, callable $handler): void
+    {
+        [$regex, $names] = self::compile($pattern);
+        $this->routes[] = [
+            'method'  => strtoupper($method),
+            'regex'   => $regex,
+            'names'   => $names,
+            'handler' => $handler,
+        ];
+    }
+
+    public function get(string $p, callable $h): void    { $this->add('GET', $p, $h); }
+    public function post(string $p, callable $h): void   { $this->add('POST', $p, $h); }
+    public function patch(string $p, callable $h): void  { $this->add('PATCH', $p, $h); }
+    public function delete(string $p, callable $h): void { $this->add('DELETE', $p, $h); }
+
+    public function dispatch(Request $req): Response
+    {
+        $methodMatchedButNotPath = false;
+
+        foreach ($this->routes as $r) {
+            if (!preg_match($r['regex'], $req->path, $m)) {
+                continue;
+            }
+            if ($r['method'] !== $req->method) {
+                $methodMatchedButNotPath = true;
+                continue;
+            }
+
+            $params = [];
+            foreach ($r['names'] as $i => $name) {
+                $val = $m[$i + 1] ?? '';
+                $params[$name] = ctype_digit($val) ? (int) $val : $val;
+            }
+
+            $result = ($r['handler'])($req, $params);
+            if ($result instanceof Response) {
+                return $result;
+            }
+            if (is_string($result)) {
+                return Response::html($result);
+            }
+            if (is_array($result) || is_object($result) || is_bool($result) || is_null($result)) {
+                return Response::json($result);
+            }
+            return Response::text((string) $result);
+        }
+
+        if ($methodMatchedButNotPath) {
+            return Response::text('Method Not Allowed', 405);
+        }
+        return Response::text('Not Found', 404);
+    }
+
+    /** @return array{0:string,1:list<string>} */
+    private static function compile(string $pattern): array
+    {
+        $names = [];
+        $regex = preg_replace_callback(
+            '/\{([A-Za-z_][A-Za-z0-9_]*)\}/',
+            function ($m) use (&$names) {
+                $names[] = $m[1];
+                return '([^/]+)';
+            },
+            $pattern
+        );
+        return ['#^' . $regex . '$#', $names];
+    }
+}

+ 74 - 0
src/Http/View.php

@@ -0,0 +1,74 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http;
+
+/**
+ * Minimal PHP template renderer. Templates live under `views/`, receive the
+ * data array as extracted local variables, and are wrapped by `layout.php`
+ * when `$layout` is provided.
+ */
+final class View
+{
+    public function __construct(
+        private readonly string $viewsDir
+    ) {
+    }
+
+    /**
+     * Render a view and return the HTML.
+     *
+     * @param string $name   view name, e.g. 'home' → views/home.php
+     * @param array<string,mixed> $data
+     * @param string|null $layout e.g. 'layout' → views/layout.php wraps $content
+     */
+    public function render(string $name, array $data = [], ?string $layout = 'layout'): string
+    {
+        $content = $this->renderRaw($name, $data);
+
+        if ($layout === null) {
+            return $content;
+        }
+
+        return $this->renderRaw($layout, ['content' => $content] + $data);
+    }
+
+    /** @param array<string,mixed> $data */
+    public function renderRaw(string $name, array $data): string
+    {
+        $file = $this->resolve($name);
+
+        $render = static function (string $__file, array $__data): string {
+            extract($__data, EXTR_SKIP);
+            ob_start();
+            try {
+                require $__file;
+            } catch (\Throwable $e) {
+                ob_end_clean();
+                throw $e;
+            }
+            return (string) ob_get_clean();
+        };
+
+        return $render($file, $data);
+    }
+
+    private function resolve(string $name): string
+    {
+        $path = $this->viewsDir . DIRECTORY_SEPARATOR . $name . '.php';
+        if (!is_file($path)) {
+            throw new \RuntimeException("View not found: {$name} ({$path})");
+        }
+        return $path;
+    }
+}
+
+/** Escape for HTML. Intended to be imported as a function from templates. */
+function e(mixed $v): string
+{
+    if ($v === null) {
+        return '';
+    }
+    return htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+}

+ 0 - 0
tests/.gitkeep


+ 38 - 0
views/home.php

@@ -0,0 +1,38 @@
+<?php
+/** @var int    $schemaVersion */
+/** @var string $dbPath */
+/** @var string $appEnv */
+use function App\Http\e;
+?>
+<section class="space-y-6">
+    <div>
+        <h1 class="text-2xl font-semibold tracking-tight">Hello 👋</h1>
+        <p class="text-slate-600 mt-1">
+            The skeleton is up. Auth, domain, and UI land in the next phases.
+        </p>
+    </div>
+
+    <div class="rounded-lg border bg-white p-4">
+        <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider">Runtime</h2>
+        <dl class="mt-3 grid grid-cols-[max-content_1fr] gap-x-6 gap-y-1 text-sm">
+            <dt class="text-slate-500">PHP</dt>
+            <dd class="font-mono"><?= e(PHP_VERSION) ?></dd>
+
+            <dt class="text-slate-500">APP_ENV</dt>
+            <dd class="font-mono"><?= e($appEnv) ?></dd>
+
+            <dt class="text-slate-500">SQLite file</dt>
+            <dd class="font-mono break-all"><?= e($dbPath) ?></dd>
+
+            <dt class="text-slate-500">Schema version</dt>
+            <dd class="font-mono"><?= e($schemaVersion) ?></dd>
+        </dl>
+    </div>
+
+    <div class="rounded-lg border bg-white p-4">
+        <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider">Health checks</h2>
+        <ul class="mt-3 text-sm space-y-1">
+            <li><a class="text-blue-700 hover:underline" href="/healthz"><code>/healthz</code></a> — liveness probe</li>
+        </ul>
+    </div>
+</section>

+ 31 - 0
views/layout.php

@@ -0,0 +1,31 @@
+<?php /** @var string $content */ /** @var string $title */ use function App\Http\e; ?>
+<!doctype html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width,initial-scale=1">
+    <title><?= e($title ?? 'Sprint Planner') ?></title>
+    <script src="https://cdn.tailwindcss.com"></script>
+    <script src="https://code.jquery.com/jquery-3.7.1.min.js"
+            integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo="
+            crossorigin="anonymous"></script>
+    <link rel="stylesheet"
+          href="https://code.jquery.com/ui/1.13.3/themes/base/jquery-ui.css">
+    <script src="https://code.jquery.com/ui/1.13.3/jquery-ui.min.js"
+            integrity="sha256-sw0iNNXmOJbQhYFuC9OF2kOlD5KQKe1y5lfBn4C4Sjg="
+            crossorigin="anonymous"></script>
+</head>
+<body class="bg-slate-50 text-slate-900 antialiased">
+    <header class="border-b bg-white">
+        <div class="max-w-7xl mx-auto px-4 py-3 flex items-center gap-4">
+            <a href="/" class="font-semibold tracking-tight">Sprint Planner</a>
+            <nav class="ml-auto text-sm text-slate-600">
+                <span class="opacity-60">Phase 1 — skeleton</span>
+            </nav>
+        </div>
+    </header>
+    <main class="max-w-7xl mx-auto px-4 py-6">
+        <?= $content ?>
+    </main>
+</body>
+</html>