diff --git a/db/db.ddl b/db/db.ddl
index 378c9d9e3c41fe291c43f032ab993f0387d3ed9c..f6dc6f5beada03e45c29e40d127817c76175fd5c 100644
--- a/db/db.ddl
+++ b/db/db.ddl
@@ -151,6 +151,7 @@ CREATE TABLE contests (
 	round_id		int		NOT NULL REFERENCES rounds(round_id),
 	place_id		int		NOT NULL REFERENCES places(place_id),
 	state			round_state	NOT NULL DEFAULT 'preparing',	-- používá se, pokud round.state='delegate', jinak kopíruje round.state
+	scoretable_id		int		DEFAULT NULL,			-- odkaz na snapshot představující oficiální výsledkovou listinu soutěže
 	UNIQUE (round_id, place_id)
 );
 
@@ -439,3 +440,18 @@ CREATE TABLE scan_pages (
 	--	-4	pro stránku, která nepatří do této soutěže
 	UNIQUE (job_id, file_nr, page_nr)
 );
+
+-- Uložené výsledkové listiny (pro zveřejnění)
+
+CREATE TABLE score_tables (
+	scoretable_id	serial		PRIMARY KEY,
+	contest_id	int		NOT NULL REFERENCES contests(contest_id) ON DELETE CASCADE,	-- soutěž ke které patří
+	created_at	timestamp with time zone	NOT NULL DEFAULT CURRENT_TIMESTAMP,		-- datum vytvoření snapshotu
+	created_by	int		NOT NULL REFERENCES users(user_id),				-- autor snapshotu
+	score_mode	score_mode	NOT NULL,							-- mód výsledkovky
+	note		text		NOT NULL,							-- poznámka viditelná pro orgy
+	tasks		jsonb		NOT NULL,							-- seznam názvů a kódů úloh
+	rows		jsonb		NOT NULL							-- seznam řádků výsledkové listiny
+);
+
+ALTER TABLE contests ADD CONSTRAINT "contests_scoretable_id" FOREIGN KEY (scoretable_id) REFERENCES score_tables(scoretable_id);
diff --git a/db/upgrade-20211204.sql b/db/upgrade-20211204.sql
new file mode 100644
index 0000000000000000000000000000000000000000..eecfd5bf5a714b538c6ab5442f24be8adfebecab
--- /dev/null
+++ b/db/upgrade-20211204.sql
@@ -0,0 +1,15 @@
+SET ROLE 'mo_osmo';
+
+CREATE TABLE score_tables (
+	scoretable_id	serial		PRIMARY KEY,
+	contest_id	int		NOT NULL REFERENCES contests(contest_id) ON DELETE CASCADE,	-- soutěž ke které patří
+	created_at	timestamp with time zone	NOT NULL DEFAULT CURRENT_TIMESTAMP,		-- datum vytvoření snapshotu
+	created_by	int		NOT NULL REFERENCES users(user_id),				-- autor snapshotu
+	score_mode	score_mode	NOT NULL,							-- mód výsledkovky
+	note		text		NOT NULL,							-- poznámka viditelná pro orgy
+	tasks		jsonb		NOT NULL,							-- seznam názvů a kódů úloh
+	rows		jsonb		NOT NULL							-- seznam řádků výsledkové listiny
+);
+
+ALTER TABLE contests ADD COLUMN
+	scoretable_id	int		DEFAULT NULL REFERENCES score_tables(scoretable_id);
diff --git a/mo/db.py b/mo/db.py
index b9f1d42693cff5c9bad3cada8a2a000346fab85b..af3d60184be11bb64566afc4ea681fdb4b013209 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -10,7 +10,7 @@ import re
 from sqlalchemy import \
     Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, \
     text, func, \
-    create_engine, inspect, select, or_, and_
+    create_engine, inspect, select
 from sqlalchemy.engine import Engine
 from sqlalchemy.orm import relationship, sessionmaker, Session, class_mapper, joinedload, aliased
 from sqlalchemy.orm.attributes import get_history
@@ -391,10 +391,12 @@ class Contest(Base):
     round_id = Column(Integer, ForeignKey('rounds.round_id'), nullable=False)
     place_id = Column(Integer, ForeignKey('places.place_id'), nullable=False)
     state = Column(Enum(RoundState, name='round_state'), nullable=False, server_default=text("'preparing'::round_state"))
+    scoretable_id = Column(Integer, ForeignKey('score_tables.scoretable_id'), nullable=True)
 
     master = relationship('Contest', primaryjoin='Contest.master_contest_id == Contest.contest_id', remote_side='Contest.contest_id', post_update=True)
     place = relationship('Place')
     round = relationship('Round')
+    scoretable = relationship('ScoreTable', primaryjoin='Contest.scoretable_id == ScoreTable.scoretable_id')
 
     def is_subcontest(self) -> bool:
         return self.master_contest_id != self.contest_id
@@ -869,6 +871,22 @@ SCAN_PAGE_CONTINUE = -3
 SCAN_PAGE_UFO = -4
 
 
+class ScoreTable(Base):
+    __tablename__ = 'score_tables'
+
+    scoretable_id = Column(Integer, primary_key=True, server_default=text("nextval('score_tables_scoretable_id_seq'::regclass)"))
+    contest_id = Column(Integer, ForeignKey('contests.contest_id', ondelete='CASCADE'), nullable=False)
+    created_at = Column(DateTime(True), nullable=False, server_default=text("CURRENT_TIMESTAMP"))
+    created_by = Column(Integer, ForeignKey('users.user_id'), nullable=False)
+    score_mode = Column(Enum(RoundScoreMode, name='score_mode'), nullable=False)
+    note = Column(Text, nullable=False)
+    tasks = Column(JSONB, nullable=False)
+    rows = Column(JSONB, nullable=False)
+
+    user = relationship('User')
+    contest = relationship('Contest', primaryjoin='Contest.scoretable_id == ScoreTable.scoretable_id')
+
+
 _engine: Optional[Engine] = None
 _session: Optional[Session] = None
 flask_db: Any = None
diff --git a/mo/web/org_score.py b/mo/web/org_score.py
index 1e5c0681e246d20b3ad157d5c01778f93a0c0957..1370d552f35acc186f3216480ab4726087493869 100644
--- a/mo/web/org_score.py
+++ b/mo/web/org_score.py
@@ -1,7 +1,9 @@
-from flask import render_template, request
+import decimal
+from flask import g, render_template, request
 from flask.helpers import flash, url_for
-from typing import List, Optional, Union
+from typing import Iterable, List, Optional, Tuple, Union
 from flask_wtf.form import FlaskForm
+import json
 import werkzeug.exceptions
 from werkzeug.utils import redirect
 import wtforms
@@ -12,33 +14,9 @@ from mo.rights import Right
 from mo.score import Score
 from mo.web import app
 from mo.web.org_contest import get_context
-from mo.web.table import Cell, CellInput, CellLink, Column, Row, Table, cell_pion_link
+from mo.web.table import Cell, CellInput, CellLink, Column, Row, Table, OrderCell, cell_pion_link
 from mo.util_format import format_decimal, inflect_number
-
-
-class OrderCell(Cell):
-    place: int
-    span: int
-    continuation: bool
-
-    def __init__(self, place: int, span: int = 1, continuation: bool = False):
-        self.place = place
-        self.span = span
-        self.continuation = continuation
-
-    def __str__(self) -> str:
-        if self.span == 1:
-            return f"{self.place}."
-        else:
-            return f"{self.place}.–{self.place + self.span - 1}."
-
-    def to_html(self) -> str:
-        if self.continuation:
-            return ""  # covered by rowspan cell above this one
-        elif self.span == 1:
-            return f"<td>{self.__str__()}"
-        else:
-            return f"<td rowspan='{self.span}'>{self.__str__()}"
+from mo.web.user import scoretable_construct
 
 
 class SolPointsCell(Cell):
@@ -84,6 +62,11 @@ class ScoreEditForm(FlaskForm):
     submit = wtforms.SubmitField("Uložit zjednoznačnění")
 
 
+class ScoreSnapshotForm(FlaskForm):
+    note = wtforms.TextField("Poznámka k verzi (pro organizátory)")
+    submit_snapshot = wtforms.SubmitField("Uložit současnou verzi")
+
+
 @app.route('/org/contest/r/<int:round_id>/score')
 @app.route('/org/contest/r/<int:round_id>/score/edit', methods=('GET', 'POST'), endpoint="org_score_edit")
 @app.route('/org/contest/r/<int:round_id>/h/<int:hier_id>/score')
@@ -112,6 +95,7 @@ def org_score(round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_
     messages = score.get_messages()
 
     edit_form: Optional[ScoreEditForm] = None
+    snapshot_form: Optional[ScoreSnapshotForm] = None
 
     if is_edit:
         edit_form = ScoreEditForm()
@@ -254,15 +238,186 @@ def org_score(round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_
     group_rounds = round.get_group_rounds(True)
     group_rounds.sort(key=lambda r: r.round_code())
 
+    snapshots_count = db.get_count(sess.query(db.ScoreTable).filter_by(contest_id=ct_id))
+
+    if ctx.rights.have_right(Right.manage_contest):
+        snapshot_form = ScoreSnapshotForm()
+
     if format == "":
         return render_template(
             'org_score.html',
             ctx=ctx,
-            contest=contest, round=round, tasks=tasks,
+            tasks=tasks,
             table=table, messages=messages,
             group_rounds=group_rounds,
-            round_id=round_id, ct_id=ct_id,
-            edit_form=edit_form,
+            snapshots_count=snapshots_count,
+            edit_form=edit_form, snapshot_form=snapshot_form,
+        )
+    else:
+        return table.send_as(format)
+
+
+class SetFinalScoretableForm(FlaskForm):
+    scoretable_id = wtforms.IntegerField()
+    submit_set_final = wtforms.SubmitField("Zveřejnit")
+    submit_hide = wtforms.SubmitField("Skrýt")
+
+
+class OrderKeyEncoder(json.JSONEncoder):
+    def encode(self, o):
+        if isinstance(o, Iterable) and (not isinstance(o, str)):
+            return '[' + ', '.join(map(self.encode, o)) + ']'
+        if isinstance(o, decimal.Decimal):
+            return f'{o.normalize():f}'  # using normalize() gets rid of trailing 0s, using ':f' prevents scientific notation
+        return super().encode(o)
+
+
+@app.route('/org/contest/c/<int:ct_id>/score/snapshots', methods=('GET', 'POST'))
+def org_score_snapshots(ct_id: int):
+    ctx = get_context(ct_id=ct_id)
+    assert ctx.contest
+
+    if not ctx.rights.have_right(Right.view_contestants):
+        raise werkzeug.exceptions.Forbidden()
+
+    sess = db.get_session()
+    scoretables = sess.query(db.ScoreTable).filter_by(contest_id=ct_id).all()
+
+    snapshot_form: Optional[ScoreSnapshotForm] = None
+    set_final_form: Optional[SetFinalScoretableForm] = None
+
+    if ctx.rights.have_right(Right.manage_contest):
+        snapshot_form = ScoreSnapshotForm()
+        if snapshot_form.validate_on_submit() and snapshot_form.submit_snapshot.data:
+
+            score = Score(ctx.round.master, ctx.contest)
+            tasks = score.get_tasks()
+            results = score.get_sorted_results()
+
+            snapshot_tasks: List[Tuple[str, str]] = [
+                (task.code, task.name) for task in tasks
+            ]
+
+            snapshot_rows = []
+            for result in results:
+                snapshot_row = {
+                    'order': result.order.__dict__,
+                    'winner': result.winner,
+                    'successful': result.successful,
+                    'name': result.user.full_name(),
+                    'school': result.pant.school_place.name or "?",
+                    'grade': result.pant.grade,
+                    'tasks': [],
+                    'total_points': format_decimal(result.get_total_points()),
+                    'birth_year': result.pant.birth_year,
+                    'order_key': json.dumps(result._order_key, cls=OrderKeyEncoder)
+                }
+                sols = result.get_sols_map()
+                for task in tasks:
+                    sol = sols.get(task.task_id)
+                    snapshot_row['tasks'].append(format_decimal(sol.points) if sol else None)
+                snapshot_rows.append(snapshot_row)
+
+            score_table = db.ScoreTable(
+                contest_id=ct_id,
+                user=g.user,
+                score_mode=ctx.round.score_mode,
+                note=snapshot_form.note.data,
+                tasks=snapshot_tasks,
+                rows=snapshot_rows,
+            )
+            sess.add(score_table)
+            sess.flush()
+            mo.util.log(
+                type=db.LogType.contest,
+                what=ctx.contest.contest_id,
+                details={
+                    'action': 'score-snapshot-created',
+                    'scoretable_id': score_table.scoretable_id,
+                },
+            )
+            sess.commit()
+            app.logger.info(f"Nový snapshot výsledkové listiny #{score_table.scoretable_id} pro soutěž #{ctx.contest.contest_id} vytvořen")
+            flash("Současný stav výsledkové listiny uložen, nyní můžete tuto verzi zveřejnit.", "success")
+            return redirect(ctx.url_for('org_score_snapshots'))
+
+        set_final_form = SetFinalScoretableForm()
+        if set_final_form.validate_on_submit():
+            found = False
+            scoretable_id = set_final_form.scoretable_id.data
+            for scoretable in scoretables:
+                if scoretable.scoretable_id == scoretable_id:
+                    found = True
+                    break
+            if found and set_final_form.submit_set_final:
+                ctx.contest.scoretable_id = scoretable_id
+                mo.util.log(
+                    type=db.LogType.contest,
+                    what=ctx.contest.contest_id,
+                    details={
+                        'action': 'score-publish',
+                        'scoretable_id': scoretable_id,
+                    },
+                )
+                sess.commit()
+                app.logger.info(f"Zveřejněna výsledková listina #{scoretable_id} pro soutěž #{ctx.contest.contest_id}")
+                flash("Výsledková listina zveřejněna.", "success")
+            elif set_final_form.submit_hide.data:
+                ctx.contest.scoretable_id = None
+                mo.util.log(
+                    type=db.LogType.contest,
+                    what=ctx.contest.contest_id,
+                    details={
+                        'action': 'score-hide',
+                    },
+                )
+                sess.commit()
+                app.logger.info(f"Skryta výsledková listina pro soutěž #{ctx.contest.contest_id}")
+                flash("Výsledková listina skryta.", "success")
+            else:
+                flash("Neznámé ID výsledkové listiny.", "danger")
+            return redirect(ctx.url_for('org_score_snapshots'))
+
+    return render_template(
+        'org_score_snapshots.html',
+        ctx=ctx,
+        scoretables=scoretables,
+        set_final_form=set_final_form
+    )
+
+
+@app.route('/org/contest/c/<int:ct_id>/score/<int:scoretable_id>')
+def org_score_snapshot(ct_id: int, scoretable_id: int):
+    ctx = get_context(ct_id=ct_id)
+    assert ctx.contest
+
+    if not ctx.rights.have_right(Right.view_contestants):
+        raise werkzeug.exceptions.Forbidden()
+
+    format = request.args.get('format', "")
+    sess = db.get_session()
+
+    scoretable = sess.query(db.ScoreTable).get(scoretable_id)
+    if not scoretable or scoretable.contest_id != ct_id:
+        raise werkzeug.exceptions.NotFound()
+
+    columns, table_rows = scoretable_construct(scoretable, format != "")
+    # columns.append(Column(key='order_key', name='order_key', title='Třídící klíč'))
+
+    filename = f"vysledky_{ctx.round.year}-{ctx.round.category}-{ctx.round.level}_oblast_{ctx.contest.place.code or ctx.contest.place.place_id}"
+    table = Table(
+        table_class="data full center",
+        columns=columns,
+        rows=table_rows,
+        filename=filename,
+    )
+
+    if format == "":
+        return render_template(
+            'org_score_snapshot.html',
+            ctx=ctx,
+            table=table,
+            scoretable=scoretable,
         )
     else:
         return table.send_as(format)
diff --git a/mo/web/table.py b/mo/web/table.py
index 95995dc2d25990aa44e1e750f123f53ee1ae9810..f95571a34282f974d6f807535a59c97e55b69e9f 100644
--- a/mo/web/table.py
+++ b/mo/web/table.py
@@ -116,6 +116,31 @@ class CellMarkup(Cell):
         return self.html
 
 
+class OrderCell(Cell):
+    place: int
+    span: int
+    continuation: bool
+
+    def __init__(self, place: int, span: int = 1, continuation: bool = False):
+        self.place = place
+        self.span = span
+        self.continuation = continuation
+
+    def __str__(self) -> str:
+        if self.span == 1:
+            return f"{self.place}."
+        else:
+            return f"{self.place}.–{self.place + self.span - 1}."
+
+    def to_html(self) -> str:
+        if self.continuation:
+            return ""  # covered by rowspan cell above this one
+        elif self.span == 1:
+            return f"<td>{self.__str__()}"
+        else:
+            return f"<td rowspan='{self.span}'>{self.__str__()}"
+
+
 class Table:
     columns: Sequence[Column]
     rows: Iterable[Row]
diff --git a/mo/web/templates/doc_org.html b/mo/web/templates/doc_org.html
index 8855d382b6780bd9cbb8219324704fc8492f46fa..9b4601598a2531e3d65a69ec5a339feba2169eeb 100644
--- a/mo/web/templates/doc_org.html
+++ b/mo/web/templates/doc_org.html
@@ -207,4 +207,46 @@ a po soutěži naskenujete, OSMO umí skeny automaticky roztřídit podle úloh
 	při zpracování skenů je potřeba je zadat ručně.
 </ul>
 
+<h3>Bodování a výsledkové listiny</h3>
+
+<p>V nastavení soutěžního kola lze určit několik vlastností pro bodování:
+
+<ul>
+<li><b>přesnost bodování</b> – jestli lze zadávat jen celé body, půlbody nebo desetinné body
+<li><b>hranice bodů pro úspěšné řešitele a vítěze</b> – pro jejich automatické vyznačení
+	ve výsledkové listině, typicky se nastavuje až po uzavření soutěže před
+	zveřejněním výsledkové listiny
+<li><b>mód výsledkové listiny</b> – určuje algoritmus, kterým se výsledková listina sestavuje,
+	v současnosti jsou dostupné tyto:
+	<ul>
+	<li><b>Základní se sdílenými místy</b> – pořadí je určeno součtem bodů za všechny úlohy,
+		při stejném počtu bodů vznikají sdílená místa
+	<li><b>Jednoznačné pořadí podle pravidel MO</b> ­– výpočet podle pravidel pro
+		<a href="http://www.matematickaolympiada.cz/media/41276/pokyny17.pdf">Vyhodnocování krajských a ústředních kol</a>
+		snažící se o jednoznačné pořadí
+	</ul>
+</ul>
+
+<p>Dále se v soutěžním kole definují <b>úlohy</b>, které mohou (ale nemusí) mít
+stanovený maximální počet bodů. Body za jednotlivé úlohy se pak dají vyplňovat
+přes tabulku úlohy na webu, nebo importem souboru s body.
+
+<p><b>Výsledkové listiny</b> systém zobrazuje hlavně pro jednotlivé soutěže, ale
+lze si nechat zobrazit výsledkovou listinu pro kteroukoliv úroveň hierarchie
+kola (v takovém případě může obsahovat sdružené výsledky z více soutěží).
+
+<p>Pokud je potřeba zaznamenat <b>výsledek losování</b> pro zjednoznačnění
+pořadí, může to udělat garant dané soutěže na stránce s výsledkovou listinou
+pomocí tlačítka „Zjednoznačnit pořadí“.
+
+<p>Pro <b>vydání oficiální výsledkové listiny</b> (kterou lze zveřejnit i
+řešitelům) nejprve musí garant soutěže <b>uložit současnou verzi</b> výsledkové
+listiny – tím vznikne uložená verze, o které systém zaručuje, že se již nebude
+měnit. Toto je možné udělat jen u výsledkové listiny pro konkrétní soutěž (nelze
+to učinit pro sdružené výsledkové listiny v hierarchii soutěžního kola).
+
+<p>Po uložení verze výsledkové listiny ji může garant soutěže <b>zveřejnit</b>.
+Tím se výsledková listina zobrazí soutěžícím v jejich rozhraní, pokud je soutěž
+ve stavu <i>ukončeno</i>.
+
 {% endblock %}
diff --git a/mo/web/templates/org_contest.html b/mo/web/templates/org_contest.html
index 1f33dbe4eaa7ff6505e17d67184084ef32df85bb..b6f438df4dab6b1b1ba3c25f85b3675a97ab896d 100644
--- a/mo/web/templates/org_contest.html
+++ b/mo/web/templates/org_contest.html
@@ -46,6 +46,11 @@
 {% else %}
 	–
 {% endif %}
+{% if state in [RoundState.grading, RoundState.closed] %}
+	<tr><td>Oficiální výsledková listina<td>
+		{% if contest.scoretable %}<a href="{{ ctx.url_for('org_score_snapshot', scoretable_id=contest.scoretable_id) }}">Zveřejněna</a>
+		{% else %}<i>zatím není</i>{% endif %}
+{% endif %}
 </table>
 
 <div class="btn-group">
diff --git a/mo/web/templates/org_score.html b/mo/web/templates/org_score.html
index 5aa15a3890f8aecda1c3bd12401b98380e281089..a725ddb86d5913f35ae49f8be059e8ea7b66d0e6 100644
--- a/mo/web/templates/org_score.html
+++ b/mo/web/templates/org_score.html
@@ -1,5 +1,7 @@
 {% extends "base.html" %}
 {% import "bootstrap/wtf.html" as wtf %}
+{% set round = ctx.round %}
+{% set contest = ctx.contest %}
 
 {% block title %}
 {{ round.round_code() }}: Výsledky pro {{ round.name|lower }} kategorie {{ round.category }}{% if contest %} {{ contest.place.name_locative() }}{% endif %}
@@ -64,6 +66,16 @@ Rozkliknutím bodů se lze dostat na detail daného řešení.</p>
 {% endif %}
 {% endif %}
 
+{% if contest and (snapshots_count or snapshot_form) %}
+<p>
+{% if snapshots_count %}
+	K této výsledkové listině {{ snapshots_count|inflected_by('existuje', 'existují', 'existují')}}
+	<strong><a href="{{ ctx.url_for('org_score_snapshots') }}">{{ snapshots_count|inflected('uložená verze', 'uložené verze', 'uložených verzí') }}</a></strong>,
+	{% if contest.scoretable_id %}jedna z nich je{% else %}žádná z nich není{% endif %} vydána jako oficiální výsledková listina.
+{% endif %}
+{% if snapshot_form %}Uložit současnou verzi výsledkové listiny můžete formulářem na spodku stránky.{% endif %}
+{% endif %}
+
 {% if edit_form %}
 <p><strong>Zjednoznačnění pořadí:</strong> U soutěžících na sdílených pozicích vyplňte číslo do políčka na konci řádku. Třídí se vzestupně od nejmenšího, prázdné políčko se považuje za nulu.</p>
 <form method="POST" class="form form-horizontal" action="">
@@ -82,4 +94,17 @@ Rozkliknutím bodů se lze dostat na detail daného řešení.</p>
 	<a class="btn btn-default pull-right" href="{{ ctx.url_for('org_score_edit') }}">Zjednoznačnit pořadí</a><br>
 {% endif %}
 
+{% if snapshot_form %}
+<div class="form-frame" style="margin-top: 20px; padding-top: 0px;">
+	<h4>Uložit současnou verzi výsledkové listiny</h4>
+	<p>Uložené verze výsledkové listiny odpovídají stavu k určitému datu. Jedna z nich může být označena jako oficiální a zveřejněna.</p>
+	<form method="post" action="{{ ctx.url_for('org_score_snapshots') }}" class="form form-inline">
+		{{ snapshot_form.csrf_token }}
+		{{ wtf.form_field(snapshot_form.note) }}
+		<input type="submit" name="submit_snapshot" class="btn btn-primary" value="Uložit současnou verzi">
+	</form>
+	</div>
+</div>
+{% endif %}
+
 {% endblock %}
diff --git a/mo/web/templates/org_score_snapshot.html b/mo/web/templates/org_score_snapshot.html
new file mode 100644
index 0000000000000000000000000000000000000000..3eb9f7edfc4c1a0f8bf288c46993cdf0b756ee67
--- /dev/null
+++ b/mo/web/templates/org_score_snapshot.html
@@ -0,0 +1,30 @@
+{% extends "base.html" %}
+
+{% block title %}
+{{ ctx.round.round_code() }}: Uložená verze výsledkové listiny pro {{ ctx.round.name|lower }} kategorie {{ ctx.round.category }} {{ ctx.contest.place.name_locative() }}
+{% endblock %}
+{% block breadcrumbs %}
+{{ ctx.breadcrumbs(action="Uložená verze výsledkové listiny") }}
+{% endblock %}
+
+{% block pretitle %}
+<div class="btn-group pull-right">
+	<a class="btn btn-default" href="{{ ctx.url_for('org_score') }}">Aktuální výsledky</a>
+</div>
+{% endblock %}
+{% block body %}
+
+<p>Výsledková listina odpovídající stavu k {{ scoretable.created_at|timeformat }}. Lze ji
+zveřejnit jako oficiální výsledkovou listinu v přehledu všech uložených verzí výsledkových listin pro tuto soutěž.</p>
+
+<table class='data'>
+	<tr><td>Vygenerováno:<th>{{ scoretable.created_at|timeformat }}
+	<tr><td>Autor:<td>{{ scoretable.user|user_link }}
+	<tr><td>Mód výsledkové listiny:<td>{{ scoretable.score_mode.friendly_name() }}
+	<tr><td>Oficiální výsledková listina:<th>{{ "ano" if scoretable.scoretable_id == ctx.contest.scoretable_id else "ne" }}
+	{% if scoretable.note %}<tr><td>Poznámka:<td>{{ scoretable.note }}{% endif %}
+</table>
+
+{{ table.to_html() }}
+
+{% endblock %}
diff --git a/mo/web/templates/org_score_snapshots.html b/mo/web/templates/org_score_snapshots.html
new file mode 100644
index 0000000000000000000000000000000000000000..26322244651bbcaab99810dbcd054a370051b8e9
--- /dev/null
+++ b/mo/web/templates/org_score_snapshots.html
@@ -0,0 +1,53 @@
+{% extends "base.html" %}
+
+{% block title %}
+{{ ctx.round.round_code() }}: Uložené výsledkové listiny {{ ctx.round.name|lower }} kategorie {{ ctx.round.category }} {{ ctx.contest.place.name_locative() }}
+{% endblock %}
+{% block breadcrumbs %}
+{{ ctx.breadcrumbs(action="Uložené výsledkové listiny") }}
+{% endblock %}
+
+{% block pretitle %}
+<div class="btn-group pull-right">
+	<a class="btn btn-default" href="{{ ctx.url_for('org_score') }}">Aktuální výsledky</a>
+</div>
+{% endblock %}
+{% block body %}
+
+<p>Uložené verze výsledkové listiny odpovídají stavu k určitému datu. Jedna z nich může být označena jako oficiální a zveřejněna.</p>
+
+{% if scoretables %}
+<table class="data full">
+	<thead>
+		<tr>
+			<th>Datum</th><th>Autor</th><th>Poznámka</th><th>Akce</th>
+		</tr>
+	</thead>
+{% for scoretable in scoretables %}
+	<tr {% if ctx.contest.scoretable_id == scoretable.scoretable_id %}class="active"{% endif %}>
+		<td>{{ scoretable.created_at|timeformat }}
+		<td>{{ scoretable.user|user_link }}
+		<td>{{ scoretable.note }}
+		<td>{% if ctx.contest.scoretable_id == scoretable.scoretable_id %}<strong>Zveřejněná verze</strong><br>{% endif %}
+			<div class="btn-group">
+			<a class="btn btn-xs btn-primary" href="{{ ctx.url_for('org_score_snapshot', scoretable_id=scoretable.scoretable_id) }}">Zobrazit</a>
+			{% if set_final_form %}
+			<form method="POST" class="btn-group">
+				{{ set_final_form.csrf_token }}
+				{% if ctx.contest.scoretable_id == scoretable.scoretable_id %}
+					<input type="submit" name="submit_hide" class="btn btn-xs btn-danger" value="Zrušit zveřejnění">
+				{% else %}
+					<input type="hidden" name="scoretable_id" value="{{ scoretable.scoretable_id }}">
+					<input type="submit" name="submit_set_final" class="btn btn-xs btn-primary" value="Zveřejnit tuto verzi">
+				{% endif %}
+			</form>
+			{% endif %}
+		</div>
+	</tr>
+{% endfor %}
+</table>
+{% else %}
+<i>Žádné uložené verze dosud neexistují.</i>
+{% endif %}
+
+{% endblock %}
diff --git a/mo/web/templates/user_contest.html b/mo/web/templates/user_contest.html
index 8d670b246347262f061a07b57e0b8c6b557dd98d..805ce489af24046c76510183724317db01f144cd 100644
--- a/mo/web/templates/user_contest.html
+++ b/mo/web/templates/user_contest.html
@@ -71,6 +71,9 @@ Pokud si s tvorbou PDF nevíte rady, zkuste se podívat do <a href='https://docs
 <p>Odevzdávání bylo ukončeno. Vyčkejte prosím, až úlohy opravíme.
 {% elif state == RoundState.closed %}
 <p>Soutěžní kolo bylo ukončeno, níže si můžete prohlédnout svá ohodnocená a okomentovaná řešení.
+{% if contest.ct_state() == RoundState.closed and contest.scoretable_id %}
+Také je již zveřejněna <strong><a href="{{ url_for('user_contest_score', id=contest.contest_id) }}">výsledková listina</a></strong>.
+{% endif %}
 {% else %}
 <p>Soutěž se nachází v neznámém stavu. To by se nemělo stát :)
 {% endif %}
diff --git a/mo/web/templates/user_contest_score.html b/mo/web/templates/user_contest_score.html
new file mode 100644
index 0000000000000000000000000000000000000000..a9d0b2f8434665253d6c2d3847759f739f3c3c3f
--- /dev/null
+++ b/mo/web/templates/user_contest_score.html
@@ -0,0 +1,15 @@
+{% extends "base.html" %}
+
+{% set round = contest.round %}
+
+{% block title %}Výsledky pro {{ round.name|lower }} {{ round.year }}. ročníku kategorie {{ round.category }}: {{ contest.place.name }}{% endblock %}
+{% block breadcrumbs %}
+<li><a href='{{ url_for('user_index') }}'>Soutěže</a>
+<li><a href='{{ url_for('user_contest', id=contest.contest_id) }}'>{{ round.name }} {{ round.year }}. ročníku kategorie {{ round.category }}: {{ contest.place.name }}</a>
+<li>Výsledky
+{% endblock %}
+{% block body %}
+
+{{ table.to_html() }}
+
+{% endblock %}
diff --git a/mo/web/templates/user_index.html b/mo/web/templates/user_index.html
index 440b9a16f664150213f456f110b2329d6680a3cd..fb74e4d25a2f8e8aef69c16fa042efa3b5a74d65 100644
--- a/mo/web/templates/user_index.html
+++ b/mo/web/templates/user_index.html
@@ -36,6 +36,9 @@
 						Detail kola
 					{% endif %}
 					</a>
+					{% if contest.ct_state() == RoundState.closed and contest.scoretable_id %}
+					<a class='btn btn-xs btn-success' href="{{ url_for('user_contest_score', id=contest.contest_id) }}">Výsledková listina</a>
+					{% endif %}
 			{% endfor %}
 	</table>
 {% else %}
diff --git a/mo/web/user.py b/mo/web/user.py
index a8e3d49af07b1f56ee5c04ff93208113f10675ad..3bdd3515f745577ee9572397ed60fce9ba0df645 100644
--- a/mo/web/user.py
+++ b/mo/web/user.py
@@ -21,6 +21,7 @@ from mo.util_format import time_and_timedelta
 from mo.web import app
 import mo.web.fields as mo_fields
 import mo.web.org_round
+from mo.web.table import Column, Row, OrderCell, Table
 import mo.web.util
 
 
@@ -492,3 +493,73 @@ def user_paper(contest_id: int, paper_id: int):
         raise werkzeug.exceptions.Forbidden()
 
     return mo.web.util.send_task_paper(paper)
+
+
+def scoretable_construct(scoretable: db.ScoreTable, is_export: bool = False) -> Tuple[List[Column], List[Row]]:
+    """Pro konstrukci výsledkovky zobrazované soutěžícím. Využito i při zobrazení
+    uložených snapshotů výsledkovky v org_score.py.
+    """
+    columns = [
+        Column(key='order', name='poradi', title='Pořadí'),
+        Column(key='name', name='ucastnik', title='Účastník'),
+        Column(key='school', name='skola', title='Škola'),
+        Column(key='grade', name='rocnik', title='Ročník')
+    ]
+    if is_export:
+        columns.insert(1, Column(key='status', name='stav'))
+
+    for (code, name) in scoretable.tasks:
+        columns.append(Column(key=f'task_{code}', name=code, title=code))
+    columns.append(Column(key='total_points', name='celkove_body', title='Celkové body'))
+
+    table_rows = []
+    for row in scoretable.rows:
+        order_cell = OrderCell(place=row['order']['place'], span=row['order']['span'], continuation=row['order']['continuation'])
+        row['order'] = order_cell
+
+        html_attr = {}
+        if row['winner']:
+            row['status'] = 'vítěz'
+            html_attr = {"class": "winner", "title": "Vítěz"}
+        elif row['successful']:
+            row['status'] = 'úspěšný'
+            html_attr = {"class": "successful", "title": "Úspěšný řešitel"}
+
+        for ((code, _), points) in zip(scoretable.tasks, row['tasks']):
+            row[f'task_{code}'] = points or '–'
+
+        table_rows.append(Row(keys=row, html_attr=html_attr))
+
+    return (columns, table_rows)
+
+
+@app.route('/user/contest/<int:id>/score')
+def user_contest_score(id: int):
+    contest, pion = get_contest_pion(id, require_reg=False)
+    round = contest.round
+    format = request.args.get('format', "")
+
+    # Výsledkovku zobrazíme jen pokud je soutěž již ukončená
+    state = contest.ct_state()
+    if not contest.scoretable or state != db.RoundState.closed:
+        raise werkzeug.exceptions.NotFound()
+
+    columns, table_rows = scoretable_construct(contest.scoretable, format != "")
+    # columns.append(Column(key='order_key', name='order_key', title='Třídící klíč'))
+
+    filename = f"vysledky_{round.year}-{round.category}-{round.level}_oblast_{contest.place.code or contest.place.place_id}"
+    table = Table(
+        table_class="data full center",
+        columns=columns,
+        rows=table_rows,
+        filename=filename,
+    )
+
+    if format == "":
+        return render_template(
+            'user_contest_score.html',
+            contest=contest,
+            table=table,
+        )
+    else:
+        return table.send_as(format)