e
empeo
People & Performance Platform
DOC  PM-RESULTS-001
REV  1.0.0 · 2026-07-07
OWNER  Technical Product Owner
STATUS
READY
TO BUILD
Engineering Handoff

Performance Evaluation Results

The current screen answers "what did they get?" This build makes it answer "what does it mean, and what should I do about it?" — a decision-support report on top of empeo's existing 360° model. Everything below is derived from the clickable prototype; the prototype is the source of truth for states, formulas, and copy.

Primary user
Manager · reviewing one report
API style
REST · OpenAPI 3.1
Score scale
0–5 · % roll-up
Locale
Thai-first · i18n-ready
§01

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.

RoleGoal on this screen
ManagerReview one report deeply; decide merit / bonus / promotion. (Primary persona.)
HRAudit consistency across reports; ensure recommendations are defensible.
EmployeeSee their own results, trend, and how they compare to peers.
§02

User Stories & Scope

Manager
See an overall grade with its trend — trajectory, not just a snapshot. See each dimension split by all four sources. Have blind spots & disagreements flagged automatically. Get a transparent merit/bonus/promotion recommendation with its reasoning. Compare vs. last year and vs. another employee. Record the decision on-screen.
HR
See the same recommendation logic every manager sees, so decisions across teams are consistent and auditable.
Employee
See results, trend, and peer percentile — where I stand and what to develop.
▲ 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
§03

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
Fig.1 — Functional block diagram
EntityResponsibility
EmployeeDisplay identity + a multi-cycle trend series (year → overall %).
EvaluationCycleOne completed period. Holds label, synthesis, dimensions, peer snapshot.
DimensionWeighted competency area with a final 0–5 score, source breakdown, topics, comment.
TopicA behavior under a dimension. Topic weights within a dimension sum to the dimension's weight.
SourceScoreFour 0–5 numbers — the unit of 360° divergence analysis.
PeerGroupCohort snapshot: distribution buckets (with a you marker) + percentile.
DecisionManager's recorded outcome for a cycle.

Scale convention: scores are 0–5. weight and percentile are integers; merit is a %, bonus is a multiplier.

§04

Data Model — Relational

Stack-agnostic PostgreSQL-flavored DDL. This is the persisted source of truth; the API projects it into nested JSON.

schema.sql
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)
);
Integrity rules Topic weights within a dimension must sum to that dimension's weight. Every dimension and topic must have exactly four source_score rows. trend is derived (order cycles by year, read each overall %), never stored.
§05

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

weighted mean → percentage
weightedMean = Σ(dim.final × dim.weight) / Σ(dim.weight)   // 0..5
overallPct   = round(weightedMean × 20)                    // 0..100
Worked · Bank / 2025(4.6×30 + 3.6×25 + 4.3×25 + 4.2×20) / 100 = 4.185 → ×20 = 83.7 → 84%

5.2 Grade band  ·  5.3 Trend

overallPctGradeไทย
≥ 90Excellenceดีเยี่ยม
≥ 80Exceedsเกินความคาดหวัง
≥ 65Meetsตามเป้าหมาย
< 65Belowต่ำกว่าเป้าหมาย
Δ (last − first %)Trend
≥ +4up · แนวโน้มดีขึ้น ↗
≤ −4down · แนวโน้มลดลง ↘
elseflat · แนวโน้มคงที่ →

5.5 Gap flags — per dimension, first match wins

Let others = mean(manager, peer, org) and spread = max − min across all four sources.

#ConditionFlag
1self − others ≥ 1.0อาจเป็นจุดบอด rates self above others
2others − self ≥ 1.0ประเมินตนเองต่ำไป undervalues self
3|manager − peer| ≥ 1.0ต้องปรับเทียบ needs calibration
4others ≥ 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

BandMerit range %Bonus ×
Excellence8 – 101.5
Exceeds5 – 71.2
Meets3 – 41.0
Below0 – 20.5
merit nudge · promotion readiness
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'    'พัฒนาก่อนพิจารณาเลื่อนตำแหน่ง'
Verified against prototype Bank / 2025 — 84% Exceeds, up, pctile 22 → merit 7%, bonus 1.2×, ready soon (amber flag → "ปิด gap ก่อน").  ·  Oat / 2025 — 61% Below, down, pctile > 25 → merit 0%, bonus 0.5×, ready hold.

Percentile is "top X%" — lower is better. The recommendation is advisory; the manager records the actual decision (§06).

§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.

GET/employees/{employeeId}/cycles/{year}
Full evaluation report for one cycle — drives the single view. Returns CycleReport.
200 OK403 Forbidden404 Not found
GET/employees/{employeeId}/cycles
List completed cycles + trend series — for the year selector and trend chart.
200 OK
GET/comparisons/employees
Side-by-side of exactly two employees for a year. ?employeeIds=a,b&year=2025
200 OK400 not exactly two ids
PUT/employees/{employeeId}/cycles/{year}/decision
Record or update the manager's decision (upsert). Body: DecisionInput.
200 Saved403 Forbidden422 Validation
CycleReport (response) — key computed fields
{
  "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" }
}
§07

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.

CapabilityManagerHREmployee (self)Other
View own report
View report they manage
Per-source breakdown✓*
See recommendation
Compare two employees✓†
Record a decision
Hard requirement * Peer scores shown to the employee are aggregated (peer mean), never attributed to an individual reviewer — peer anonymity is non-negotiable.   both employees must report to that manager.
§08

Non-Functional

LocalizationThai-first — all labels, grades, flags, recommendation copy in Thai. Arabic numerals. Architect for future locales; Thai ships first.
AuditabilityRecommendation output must be reproducible from stored inputs at any later date. Version the formula — a decision recorded under v1 must still explain itself.
PerformanceSingle-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.
AccessibilityGap flags carry text label + tooltip (never colour alone). Score bars need text equivalents. Keyboard-navigable controls, visible focus.
Client ruleNo client-side recomputation of grade / merit / bonus. Clients render server-computed values.
§09

Acceptance Criteria

GIVENdims (4.6×30, 3.6×25, 4.3×25, 4.2×20)
THENoverallPct = 84, grade = 'Exceeds'. At exactly 65 → 'Meets' (inclusive).
GIVENtrend [72, 78, 84]
THENtrendDir = 'up' (Δ12 ≥ 4). Trend [80,79,78] → 'flat' (Δ−2).
GIVENself 4.7 / mgr 3.1 / peer 3.2 / org 3.3
THENflag = อาจเป็นจุดบอด (self − others = 1.5 ≥ 1.0), amber.
GIVEN84% / Exceeds / up / pctile 22, with an amber-flagged dimension
THENmerit 7, bonus 1.2, ready 'soon', promo copy includes "ปิด gap … ก่อน".
GIVEN61% / Below / down / pctile > 25
THENmerit 0, bonus 0.5, ready 'hold'.
WHENGET /comparisons/employees with one id
THEN400. No prior cycle → prevYear = null, YoY control disabled.
WHENa non-manager PUTs a decision
THEN403 and nothing is persisted. Employee viewing self → recommendation omitted, peer scores aggregated only.
§10

Edge Cases & Open Questions

Edge cases — behavior defined

Missing prior cycleprevYear = null; disable YoY compare.
< 4 sources on a dimensionSpec 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 comparisonTornado bars render equal; no tiebreak needed.
Below + up trendFalls through to 'hold' (readiness requires Exceeds/Excellence). Confirm intended.

Open questions — need a stakeholder decision

1
Merit ranges are hardcoded per band. Configurable per org / cycle, or fixed for consistency?
NEEDS DECISION
2
Bonus is band-only — trend & percentile don't affect it (unlike merit). Intended asymmetry?
NEEDS DECISION
3
Promo copy hardcodes the collaboration dimension. Should it name whichever dimension carries the blind-spot flag?
NEEDS DECISION
4
Percentile is stored, not computed. Who owns computing it, and when — batch job at cycle close?
NEEDS DECISION
5
Decision keeps one row per manager per cycle (upsert). Do we need an edit audit trail, or is last-write-wins fine?
NEEDS DECISION