Overview & Context
The evaluation-authoring flow is unchanged. This work covers reading, interpreting, comparing, and acting on results. All computed layers are deterministic functions of stored data — the same inputs always produce the same recommendation, which is what makes the output auditable.
| Role | Goal on this screen |
|---|---|
| Manager | Review one report deeply; decide merit / bonus / promotion. (Primary persona.) |
| HR | Audit consistency across reports; ensure recommendations are defensible. |
| Employee | See their own results, trend, and how they compare to peers. |
User Stories & Scope
▲ In scope
- Render results for a completed cycle (single view)
- Year-over-year comparison
- Employee-vs-employee comparison (exactly two)
- Computed: overall %, grade, trend, gap flags, percentile, recommendation
- Record a decision (merit %, bonus ×, notes)
- Source-visibility toggles
▼ Out of scope
- Creating / editing evaluations, questionnaires, weights
- Cycle lifecycle (opening / closing)
- Notifications, approvals, payroll integration
- Org-wide dashboards beyond single + comparison views
Domain Model
Employee ──< EvaluationCycle ──< Dimension ──< Topic
│ │
│ └── SourceScore {self, manager, peer, org}
│ (per dimension AND per topic)
├── PeerGroup (distribution buckets + percentile)
├── Synthesis (narrative summary)
└── Decision (merit %, bonus ×, notes) — 0..1 per manager
| Entity | Responsibility |
|---|---|
| Employee | Display identity + a multi-cycle trend series (year → overall %). |
| EvaluationCycle | One completed period. Holds label, synthesis, dimensions, peer snapshot. |
| Dimension | Weighted competency area with a final 0–5 score, source breakdown, topics, comment. |
| Topic | A behavior under a dimension. Topic weights within a dimension sum to the dimension's weight. |
| SourceScore | Four 0–5 numbers — the unit of 360° divergence analysis. |
| PeerGroup | Cohort snapshot: distribution buckets (with a you marker) + percentile. |
| Decision | Manager's recorded outcome for a cycle. |
Scale convention: scores are 0–5. weight and percentile are integers; merit is a %, bonus is a multiplier.
Data Model — Relational
Stack-agnostic PostgreSQL-flavored DDL. This is the persisted source of truth; the API projects it into nested JSON.
CREATE TABLE employee ( id UUID PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL, -- 'UX Designer (O1)' team TEXT NOT NULL, tenure_months INTEGER NOT NULL, avatar TEXT, -- emoji or asset ref accent_color TEXT -- hex, for compare theming ); CREATE TABLE evaluation_cycle ( id UUID PRIMARY KEY, employee_id UUID NOT NULL REFERENCES employee(id), year SMALLINT NOT NULL, label TEXT NOT NULL, -- 'ประจำปี 2025' synthesis TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'completed' CHECK (status IN ('completed')), percentile SMALLINT NOT NULL CHECK (percentile BETWEEN 0 AND 100), peer_role_label TEXT NOT NULL, UNIQUE (employee_id, year) ); CREATE TABLE dimension ( id UUID PRIMARY KEY, cycle_id UUID NOT NULL REFERENCES evaluation_cycle(id) ON DELETE CASCADE, name TEXT NOT NULL, -- 'การส่งมอบงาน' weight SMALLINT NOT NULL CHECK (weight BETWEEN 0 AND 100), final_score NUMERIC(2,1) NOT NULL CHECK (final_score BETWEEN 0 AND 5), comment TEXT, sort_order SMALLINT NOT NULL ); CREATE TABLE topic ( id UUID PRIMARY KEY, dimension_id UUID NOT NULL REFERENCES dimension(id) ON DELETE CASCADE, name TEXT NOT NULL, weight SMALLINT NOT NULL, sort_order SMALLINT NOT NULL ); -- One row per source, for a dimension OR a topic (exactly one FK set). CREATE TABLE source_score ( id UUID PRIMARY KEY, dimension_id UUID REFERENCES dimension(id) ON DELETE CASCADE, topic_id UUID REFERENCES topic(id) ON DELETE CASCADE, source TEXT NOT NULL CHECK (source IN ('self','manager','peer','org')), score NUMERIC(2,1) NOT NULL CHECK (score BETWEEN 0 AND 5), CHECK ((dimension_id IS NULL) <> (topic_id IS NULL)), UNIQUE (dimension_id, source), UNIQUE (topic_id, source) ); CREATE TABLE peer_bucket ( id UUID PRIMARY KEY, cycle_id UUID NOT NULL REFERENCES evaluation_cycle(id) ON DELETE CASCADE, range_label TEXT NOT NULL, -- '80–90' headcount SMALLINT NOT NULL, contains_you BOOLEAN NOT NULL DEFAULT FALSE, sort_order SMALLINT NOT NULL ); CREATE TABLE decision ( id UUID PRIMARY KEY, cycle_id UUID NOT NULL REFERENCES evaluation_cycle(id) ON DELETE CASCADE, manager_id UUID NOT NULL REFERENCES employee(id), merit_pct NUMERIC(4,1), -- nullable until recorded bonus_mult NUMERIC(3,1), notes TEXT DEFAULT '', updated_at TIMESTAMPTZ NOT NULL, UNIQUE (cycle_id, manager_id) );
source_score rows. trend is derived (order cycles by year, read each overall %), never stored.Business Logic
The exact formulas the prototype computes. Compute these server-side and return them in the response — clients never re-derive merit, bonus, or grade.
5.1 Overall score
weightedMean = Σ(dim.final × dim.weight) / Σ(dim.weight) // 0..5 overallPct = round(weightedMean × 20) // 0..100
5.2 Grade band · 5.3 Trend
| overallPct | Grade | ไทย |
|---|---|---|
| ≥ 90 | Excellence | ดีเยี่ยม |
| ≥ 80 | Exceeds | เกินความคาดหวัง |
| ≥ 65 | Meets | ตามเป้าหมาย |
| < 65 | Below | ต่ำกว่าเป้าหมาย |
| Δ (last − first %) | Trend |
|---|---|
| ≥ +4 | up · แนวโน้มดีขึ้น ↗ |
| ≤ −4 | down · แนวโน้มลดลง ↘ |
| else | flat · แนวโน้มคงที่ → |
5.5 Gap flags — per dimension, first match wins
Let others = mean(manager, peer, org) and spread = max − min across all four sources.
| # | Condition | Flag |
|---|---|---|
| 1 | self − others ≥ 1.0 | อาจเป็นจุดบอด rates self above others |
| 2 | others − self ≥ 1.0 | ประเมินตนเองต่ำไป undervalues self |
| 3 | |manager − peer| ≥ 1.0 | ต้องปรับเทียบ needs calibration |
| 4 | others ≥ 4.0 & spread ≤ 0.8 | จุดแข็งที่สอดคล้อง confirmed strength |
The amber blind-spot flag also feeds the recommendation. Colour is never the only signal — each flag carries its label + tooltip.
5.7 Recommendation engine — advisory
| Band | Merit range % | Bonus × |
|---|---|---|
| Excellence | 8 – 10 | 1.5 |
| Exceeds | 5 – 7 | 1.2 |
| Meets | 3 – 4 | 1.0 |
| Below | 0 – 2 | 0.5 |
merit = meritRange.max // default: top of band if (trend != 'up' && percentile > 25) merit = meritRange.min // not rising & not top-quartile blindSpot = any dimension has the amber 'อาจเป็นจุดบอด' flag if band == 'Excellence' && trend == 'up' && percentile <= 25 → ready = 'ready' 'พร้อมเลื่อนตำแหน่ง' elif band in ('Excellence','Exceeds') && trend == 'up' → ready = 'soon' blindSpot ? 'เตรียมเลื่อน — ปิด gap ก่อน' : 'เตรียมเลื่อนภายใน 6 เดือน' else → ready = 'hold' 'พัฒนาก่อนพิจารณาเลื่อนตำแหน่ง'
Percentile is "top X%" — lower is better. The recommendation is advisory; the manager records the actual decision (§06).
REST API Contract
All computed fields (§05) are returned by the server. Reads are resource-nested to match how the screen consumes them. Full OpenAPI 3.1 in the spec markdown.
CycleReport.?employeeIds=a,b&year=2025DecisionInput.{
"employee": { "id", "name", "role", "team", "tenure", "avatar", "accent" },
"year": 2025, "label": "ประจำปี 2025", "synthesis": "…",
"overallPct": 84, // §5.1
"grade": "Exceeds", "gradeTh": "เกินความคาดหวัง", // §5.2
"trendDir": "up", // §5.3
"prevYear": 2024, // or null — §5.4
"percentile": 22, "peerRoleLabel": "สาย UX/Design (28 คน)",
"peerBuckets": [{ "range", "count", "you" }],
"dimensions": [{
"name", "weight", "final",
"sources": { "self", "manager", "peer", "org" },
"topics": [...], "comment",
"gapFlag": { "label", "color", "tip" } | null // §5.5
}],
"recommendation": { "merit", "bonus", "promo", "ready" }, // §5.7
"decision": { "merit", "bonus", "notes", "updatedAt" }
}Permissions & Visibility
The source-visibility toggles are a display convenience, not security — the server enforces access independently. A GET on a report the caller may not see returns 403.
| Capability | Manager | HR | Employee (self) | Other |
|---|---|---|---|---|
| View own report | — | ✓ | ✓ | ✗ |
| View report they manage | ✓ | ✓ | — | ✗ |
| Per-source breakdown | ✓ | ✓ | ✓* | ✗ |
| See recommendation | ✓ | ✓ | ✗ | ✗ |
| Compare two employees | ✓† | ✓ | ✗ | ✗ |
| Record a decision | ✓ | ✗ | ✗ | ✗ |
Non-Functional
| Localization | Thai-first — all labels, grades, flags, recommendation copy in Thai. Arabic numerals. Architect for future locales; Thai ships first. |
| Auditability | Recommendation output must be reproducible from stored inputs at any later date. Version the formula — a decision recorded under v1 must still explain itself. |
| Performance | Single-report GET returns one cycle's full tree in one round trip; target < 300 ms server time. Compare fans out to at most two reports. |
| Accessibility | Gap flags carry text label + tooltip (never colour alone). Score bars need text equivalents. Keyboard-navigable controls, visible focus. |
| Client rule | No client-side recomputation of grade / merit / bonus. Clients render server-computed values. |
Acceptance Criteria
Edge Cases & Open Questions
Edge cases — behavior defined
| Missing prior cycle | prevYear = null; disable YoY compare. |
| < 4 sources on a dimension | Spec assumes all four exist. Recommend: block at authoring; results view assumes four. |
| Weights ≠ 100 | §5.1 normalizes by Σweight so math is safe — but surface a data-quality warning in authoring. |
| Ties in comparison | Tornado bars render equal; no tiebreak needed. |
| Below + up trend | Falls through to 'hold' (readiness requires Exceeds/Excellence). Confirm intended. |