feat: add diagnostic web app shell
This commit is contained in:
41
internal/webapp/assets.go
Normal file
41
internal/webapp/assets.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var assets embed.FS
|
||||
|
||||
func Handler() http.Handler {
|
||||
static, err := fs.Sub(assets, "static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
files := http.FileServer(http.FS(static))
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
serveIndex(w, r, static)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/assets/") {
|
||||
http.StripPrefix("/assets/", files).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func serveIndex(w http.ResponseWriter, r *http.Request, static fs.FS) {
|
||||
content, err := fs.ReadFile(static, "index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "web app unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(content)
|
||||
}
|
||||
36
internal/webapp/assets_test.go
Normal file
36
internal/webapp/assets_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandlerServesIndex(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Interview practice") {
|
||||
t.Fatal("expected app shell content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerServesAsset(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/assets/app.js", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "diagnostic-sessions") {
|
||||
t.Fatal("expected app script content")
|
||||
}
|
||||
}
|
||||
181
internal/webapp/static/app.js
Normal file
181
internal/webapp/static/app.js
Normal file
@@ -0,0 +1,181 @@
|
||||
const state = {
|
||||
session: null,
|
||||
selectedQuestion: null,
|
||||
lastAnswer: null,
|
||||
};
|
||||
|
||||
const els = {
|
||||
sessionForm: document.querySelector("#session-form"),
|
||||
answerForm: document.querySelector("#answer-form"),
|
||||
answerText: document.querySelector("#answer-text"),
|
||||
answerButton: document.querySelector("#answer-button"),
|
||||
questions: document.querySelector("#questions"),
|
||||
feedback: document.querySelector("#feedback"),
|
||||
status: document.querySelector("#status-line"),
|
||||
error: document.querySelector("#error-line"),
|
||||
title: document.querySelector("#session-title"),
|
||||
};
|
||||
|
||||
els.sessionForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
clearError();
|
||||
setStatus("Creating diagnostic session...");
|
||||
|
||||
const payload = {
|
||||
user_id: value("#user-id"),
|
||||
target_role: value("#target-role"),
|
||||
stack: value("#stack").split(",").map((item) => item.trim()).filter(Boolean),
|
||||
interview_timeline: value("#timeline"),
|
||||
};
|
||||
|
||||
try {
|
||||
const session = await request("/api/v1/diagnostic-sessions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
state.session = session;
|
||||
state.selectedQuestion = session.questions[0] || null;
|
||||
state.lastAnswer = null;
|
||||
renderSession();
|
||||
renderFeedback();
|
||||
setStatus(`Session ${session.id} ready`);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
setStatus("Ready");
|
||||
}
|
||||
});
|
||||
|
||||
els.answerForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
clearError();
|
||||
if (!state.session || !state.selectedQuestion) return;
|
||||
|
||||
setStatus("Submitting answer...");
|
||||
els.answerButton.disabled = true;
|
||||
|
||||
try {
|
||||
const answer = await request(`/api/v1/diagnostic-sessions/${state.session.id}/answers`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
question_id: state.selectedQuestion.id,
|
||||
answer_text: els.answerText.value,
|
||||
}),
|
||||
});
|
||||
state.lastAnswer = answer;
|
||||
renderFeedback();
|
||||
setStatus(`Answer graded as ${answer.grade.overall}`);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
setStatus("Session ready");
|
||||
} finally {
|
||||
els.answerButton.disabled = !state.selectedQuestion;
|
||||
}
|
||||
});
|
||||
|
||||
function renderSession() {
|
||||
if (!state.session) return;
|
||||
els.title.textContent = `${state.session.target_role} · ${state.session.questions.length} questions`;
|
||||
els.questions.className = "question-list";
|
||||
els.questions.innerHTML = "";
|
||||
|
||||
state.session.questions.forEach((question) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "question-button";
|
||||
button.setAttribute("aria-pressed", String(state.selectedQuestion?.id === question.id));
|
||||
button.innerHTML = `<span class="question-id">${question.id}</span>${escapeHTML(question.prompt)}`;
|
||||
button.addEventListener("click", () => {
|
||||
state.selectedQuestion = question;
|
||||
els.answerText.value = "";
|
||||
renderSession();
|
||||
setStatus(`Selected ${question.id}`);
|
||||
});
|
||||
els.questions.append(button);
|
||||
});
|
||||
|
||||
els.answerButton.disabled = !state.selectedQuestion;
|
||||
}
|
||||
|
||||
function renderFeedback() {
|
||||
if (!state.lastAnswer) {
|
||||
els.feedback.className = "feedback empty-state";
|
||||
els.feedback.textContent = "Submit an answer to see grade, evidence, and follow-up.";
|
||||
return;
|
||||
}
|
||||
|
||||
const grade = state.lastAnswer.grade;
|
||||
els.feedback.className = "feedback";
|
||||
els.feedback.innerHTML = `
|
||||
<div>
|
||||
<div class="grade">${escapeHTML(grade.overall)}</div>
|
||||
<p class="status-line">${escapeHTML(grade.strengths?.[0] || "Answer was graded.")}</p>
|
||||
</div>
|
||||
${scoreRows(grade.scores)}
|
||||
${listBlock("Gaps", grade.gaps)}
|
||||
${followUpBlock(grade.follow_up)}
|
||||
${evidenceBlock(grade.evidence)}
|
||||
`;
|
||||
}
|
||||
|
||||
function scoreRows(scores) {
|
||||
return Object.entries(scores || {})
|
||||
.map(([label, score]) => `
|
||||
<div class="metric-row">
|
||||
<span>${escapeHTML(label.replaceAll("_", " "))}</span>
|
||||
<strong>${score}/4</strong>
|
||||
</div>
|
||||
`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function listBlock(title, items = []) {
|
||||
if (!items.length) return "";
|
||||
return `<section><h2>${title}</h2><ul class="small-list">${items.map((item) => `<li>${escapeHTML(item)}</li>`).join("")}</ul></section>`;
|
||||
}
|
||||
|
||||
function followUpBlock(followUp) {
|
||||
if (!followUp?.needed) return "";
|
||||
return `<section><h2>Follow-up</h2><p class="status-line">${escapeHTML(followUp.question)}</p></section>`;
|
||||
}
|
||||
|
||||
function evidenceBlock(evidence = []) {
|
||||
if (!evidence.length) return "";
|
||||
return `<section><h2>Evidence</h2><ul class="small-list">${evidence.map((item) => `<li>${escapeHTML(item.quote || item.id)}</li>`).join("")}</ul></section>`;
|
||||
}
|
||||
|
||||
async function request(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options,
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(body.error || `Request failed: ${response.status}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function value(selector) {
|
||||
return document.querySelector(selector).value.trim();
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
els.status.textContent = message;
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
els.error.textContent = message;
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
els.error.textContent = "";
|
||||
}
|
||||
|
||||
function escapeHTML(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
68
internal/webapp/static/index.html
Normal file
68
internal/webapp/static/index.html
Normal file
@@ -0,0 +1,68 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Tutor Platform</title>
|
||||
<link rel="stylesheet" href="/assets/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="workspace">
|
||||
<aside class="setup-pane" aria-label="Diagnostic setup">
|
||||
<p class="eyebrow">Tutor Platform</p>
|
||||
<h1>Interview practice</h1>
|
||||
<p class="lede">Start a focused backend interview loop and turn one answer into evidence.</p>
|
||||
|
||||
<form id="session-form" class="stacked-form">
|
||||
<label>
|
||||
User ID
|
||||
<input id="user-id" name="user_id" value="demo-user" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Target role
|
||||
<input id="target-role" name="target_role" value="junior backend developer" />
|
||||
</label>
|
||||
<label>
|
||||
Stack
|
||||
<input id="stack" name="stack" value="go, postgres" />
|
||||
</label>
|
||||
<label>
|
||||
Timeline
|
||||
<input id="timeline" name="interview_timeline" value="30 days" />
|
||||
</label>
|
||||
<button id="start-button" type="submit">Start diagnostic</button>
|
||||
</form>
|
||||
|
||||
<p id="status-line" class="status-line" role="status">Ready</p>
|
||||
<p id="error-line" class="error-line" role="alert"></p>
|
||||
</aside>
|
||||
|
||||
<section class="practice-pane" aria-label="Diagnostic practice">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">Diagnostic</p>
|
||||
<h2 id="session-title">No active session</h2>
|
||||
</div>
|
||||
<div id="questions" class="question-list empty-state">
|
||||
Start a diagnostic session to load interview questions.
|
||||
</div>
|
||||
|
||||
<form id="answer-form" class="answer-form">
|
||||
<label for="answer-text">Answer</label>
|
||||
<textarea id="answer-text" rows="7" placeholder="Select a question, then answer with concrete production reasoning."></textarea>
|
||||
<button id="answer-button" type="submit" disabled>Submit answer</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside class="feedback-pane" aria-label="Feedback">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">Feedback</p>
|
||||
<h2>Rubric result</h2>
|
||||
</div>
|
||||
<div id="feedback" class="feedback empty-state">
|
||||
Submit an answer to see grade, evidence, and follow-up.
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
<script src="/assets/app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
232
internal/webapp/static/styles.css
Normal file
232
internal/webapp/static/styles.css
Normal file
@@ -0,0 +1,232 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f7f4;
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #eef2ec;
|
||||
--text: #18201b;
|
||||
--muted: #5b665f;
|
||||
--line: #d8dfd5;
|
||||
--accent: #19764b;
|
||||
--accent-dark: #105c39;
|
||||
--danger: #a93a2f;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 320px) minmax(360px, 1fr) minmax(280px, 360px);
|
||||
gap: 1px;
|
||||
min-height: 100vh;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.setup-pane,
|
||||
.practice-pane,
|
||||
.feedback-pane {
|
||||
background: var(--surface);
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.practice-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 10px;
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 9ch;
|
||||
font-size: clamp(42px, 6vw, 74px);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.lede {
|
||||
max-width: 28ch;
|
||||
margin: 18px 0 30px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.stacked-form,
|
||||
.answer-form {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fbfcfa;
|
||||
color: var(--text);
|
||||
padding: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 160px;
|
||||
resize: vertical;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(25, 118, 75, 0.12);
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--accent-dark);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.status-line,
|
||||
.error-line {
|
||||
min-height: 20px;
|
||||
margin: 18px 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-line {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.error-line {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.question-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.question-button {
|
||||
border: 1px solid var(--line);
|
||||
background: #fbfcfa;
|
||||
color: var(--text);
|
||||
padding: 16px;
|
||||
min-height: 72px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.question-button[aria-pressed="true"] {
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.question-id {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.grade {
|
||||
color: var(--accent);
|
||||
font-size: 38px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.small-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 100%;
|
||||
font-size: 42px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user