")
# ligne du temps
tl = []
for n in range(1, 15):
_, _, _, col = part_of(course, n)
evs = course["events"].get(n, [])
ev_html = "".join(f'{e(t)}' for k, t in evs)
tl.append(f'{n}{ev_html}')
eval_rows = "".join(f"
{e(a)}
{e(b)}
{e(c)}
{e(d)}
" for a, b, c, d in course["evaluation"])
features = [
("/seance/01/", "i-book", "Notes de cours intégrales", "Les 14 séances du manuel : définitions et formules numérotées, exemples chiffrés, pièges, contexte québécois, exercices corrigés, fiche synthèse.", False),
("/seance/03/", "i-chart", "Explorateurs interactifs", "Des graphiques à curseurs insérés dans les séances : offre et demande, cycle, sensibilité au TGA, DCF, ventilation de la dépréciation…", True),
("/entrainement/", "i-dumbbell", "Entraînement infini", "Problèmes à données aléatoires corrigés pas à pas, avec suivi de vos séries.", True),
("/quiz/", "i-quiz", "Quiz et examens blancs", f"{stats['quiz']} questions avec rétroaction, un examen blanc chronométré, vos meilleurs scores.", False),
("/jeux/", "i-game", "Jeux de révision", "Associez termes et définitions, tranchez vrai ou faux, remettez les étapes dans l’ordre.", True),
("/fiches/", "i-cards", "Fiches de révision", f"{stats['cards']} fiches recto-verso générées des définitions, du glossaire et des formules.", False),
("/outils/", "i-calc", "Calculateurs", "Les formules du cours en calculateurs : modifiez les hypothèses et voyez la valeur réagir.", False),
("/seance/01/#notes", "i-note", "Surlignage et notes", "Surlignez les passages, annotez-les, exportez vos notes en Markdown ; écoutez la séance en audio.", True),
("/progression/", "i-progress", "Tableau de bord", "Lecture, quiz, objectifs, fiches, entraînement : votre progression séance par séance.", True),
]
if course.get("has_videos"):
features.insert(1, ("/videos/", "i-video", "Séances vidéo", "Les enregistrements Zoom des cours : chapitres, transcription synchronisée cliquable, sous-titres, diapositives au fil de la vidéo.", True))
feat_html = "".join(f'
' for u, i, t, d, new in features)
report_html = f"""
Ressource transversale
Le marché de l’habitation au Québec
Rapport de référence de 106 pages : indices de prix MLS® 2005–2026, six marchés québécois, cadre macroéconomique et abordabilité. Intégralement consultable, avec un explorateur interactif des données.
')
if level == 2:
steps.append(f'{num.split(".")[-1]}{strip_tags(title_html)}')
events = course["events"].get(n, [])
ev_html = "".join(f'{e(t)}' for _, t in events)
c = ch.counts
hero = f"""
{n:02d}
Partie {part_roman} {e(part_title)} · Séance {n} · {course['code']}
' for lvl, num, t, sid in toc if lvl <= 3)
return f"
{heading}
{items}{extra}"
def glossary_page(course, chapters, entries, intro_html, has_report=True) -> str:
letters = sorted({en["letter"] for en in entries})
nav = "".join(f'{l}' for l in letters)
parts, cur = [], None
for en in entries:
if en["letter"] != cur:
cur = en["letter"]
parts.append(f'
{cur}
')
parts.append(f'
{en["term_html"]}
{en["html"]}
')
body = f"""
{page_head('Annexe B', 'Glossaire', '')}
"""
return layout(course, chapters, title="Glossaire", body=body, active="reviser", canonical="/glossaire/", has_report=has_report,
desc=f"Glossaire du cours {course['code']} : {len(entries)} termes définis, avec équivalents anglais.")
def quiz_page(course, chapters, n_questions, has_report=True) -> str:
body = f"""{page_head('S’exercer', 'Quiz et examens blancs', f'{n_questions} questions à choix multiples couvrant les 14 séances. Chaque réponse est expliquée et renvoie à la section des notes. Vos meilleurs scores sont conservés sur cet appareil ; un score de 70 % marque la séance comme terminée.')}
Chargement…
"""
return layout(course, chapters, title="Quiz", body=body, active="exercer", canonical="/quiz/", has_report=has_report)
def flash_page(course, chapters, n_cards, has_report=True) -> str:
body = f"""
{page_head('Réviser', 'Fiches de révision', f'{n_cards} fiches recto-verso générées à partir des définitions numérotées, du glossaire et des formules du cours. Retournez la carte, puis indiquez si vous la saviez : trois bonnes réponses successives la classent comme acquise.')}
"""
return layout(course, chapters, title="Fiches de révision", body=body, active="reviser", canonical="/fiches/", has_report=has_report)
def tools_page(course, chapters, n_tools, has_report=True) -> str:
body = f"""{page_head('Calculer', 'Calculateurs du cours', f'{n_tools} calculateurs reprennent les formules du manuel avec des données réalistes du marché québécois. Modifiez une hypothèse : le résultat, les étapes intermédiaires et les vérifications se recalculent instantanément.')}
Repères pédagogiques tirés des cas du cours, pas des normes : à l’examen, utilisez toujours les données de l’énoncé.
"""
return layout(course, chapters, title="Calculateurs", body=body, active="exercer", canonical="/outils/", extra_body='', has_report=has_report)
def training_page(course, chapters, n_gen, has_report=True) -> str:
body = f"""{page_head('S’exercer', 'Entraînement', f'{n_gen} types de problèmes aux données tirées au hasard : chaque énoncé est nouveau, la réponse est vérifiée avec une tolérance d’arrondi et la solution est détaillée étape par étape. Enchaînez les bonnes réponses pour allonger votre série.')}
Réponses acceptées : nombres avec ou sans espaces, virgule ou point décimal, symbole $ ou % facultatif.
"""
return layout(course, chapters, title="Entraînement", body=body, active="exercer", canonical="/entrainement/", extra_body='', has_report=has_report)
def games_page(course, chapters, sequences, has_report=True) -> str:
body = f"""{page_head('S’exercer', 'Jeux de révision', 'Trois jeux rapides construits à partir des définitions, du glossaire et des séquences du cours. Idéal pour cinq minutes de révision active.')}
Chargement…
"""
seq_json = json.dumps(sequences, ensure_ascii=False)
return layout(course, chapters, title="Jeux de révision", body=body, active="exercer", canonical="/jeux/", extra_body=f"", has_report=has_report)
def progress_page(course, chapters, meta_json: str, has_report=True) -> str:
body = f"""{page_head('Suivi', 'Ma progression', 'Tout ce que vous avez lu, réussi, coché et noté dans ce cours, sur cet appareil. Exportez votre progression pour la retrouver ailleurs.')}
Chargement…
"""
return layout(course, chapters, title="Ma progression", body=body, active="exercer", canonical="/progression/", extra_body=f"", has_report=has_report)
def map_page(course, chapters, map_json: str, has_report=True) -> str:
body = f"""{page_head('Vue d’ensemble', 'Carte du cours', 'Les 14 séances disposées par partie. Survolez ou touchez une séance pour voir ses objectifs d’apprentissage et votre avancement ; cliquez pour l’ouvrir.')}
{course_map_svg(course, chapters)}
"""
return layout(course, chapters, title="Carte du cours", body=body, active="reviser", canonical="/carte/", extra_body=f"", has_report=has_report)
def search_page(course, chapters, has_report=True) -> str:
body = f"""{page_head('Chercher', 'Recherche dans les notes', 'Chaque paragraphe, définition, formule, exemple, exercice, tableau, entrée de glossaire et section du rapport est indexé. Les résultats renvoient à l’emplacement exact.')}"""
return layout(course, chapters, title="Recherche", body=body, active="ressources", canonical="/recherche/", has_report=has_report)
def resources_page(course, chapters, rows, has_report=True) -> str:
def fi(kind):
return f'{kind.upper()}'
groups = {}
for r in rows:
groups.setdefault(r["group"], []).append(r)
parts = []
for g, items in groups.items():
trs = []
for r in items:
target = ' target="_blank" rel="noopener"' if r["kind"] == "pdf" else ""
desc = '
')
body = f"""{page_head('Ressources', 'Téléchargements', 'Le manuel, le plan de cours, les diapositives des 14 séances, les énoncés de travaux pratiques, les gabarits Excel « Dashboard » et le rapport sur le marché de l’habitation avec ses données. Les solutionnaires et les questionnaires d’examen ne sont pas publiés ici.')}{''.join(parts)}"""
return layout(course, chapters, title="Ressources", body=body, active="ressources", canonical="/ressources/", has_report=has_report)
def slides_page(course, chapters, slides, has_report=True) -> str:
tabs = "".join(f'' for s in slides)
lis = "".join(f'
' for s in slides)
body = f"""{page_head('Diapositives', 'Les 14 séances en diapositives', 'Les présentations Beamer projetées en classe, consultables ici ou téléchargeables en PDF. Les notes de cours en sont le prolongement écrit.')}
"""
return layout(course, chapters, title="Diapositives", body=body, active="ressources", canonical="/diapositives/", has_report=has_report)
def bib_page(course, chapters, entries_html, n, has_report=True, extra_title="") -> str:
body = f"""{page_head('Références', 'Bibliographie et références', f'{n} ouvrages, normes, lois, publications statistiques et sources en ligne citées dans les notes de cours{extra_title} (style auteur-année).')}
' for f in files)
body = f"""
{page_head('Ressource transversale · juillet 2026', 'Le marché de l’habitation au Québec', 'Document de référence : analyse de l’indice des prix des propriétés MLS® (janvier 2005 – juin 2026) et du contexte macroéconomique canadien (FRED). Vingt ans de prix, six marchés québécois, types de propriétés, saisonnalité, risque, abordabilité — avec les données interactives.')}
{cover_html}
Métriques clés — {e(strip_tags(kpis_html.split('data-obs="')[1].split('"')[0]) if 'data-obs="' in kpis_html else 'dernière observation')}
{kpis_html}
Explorateur interactif
Indices de prix MLS® : comparez les marchés
Choisissez les marchés, la mesure et la période : indice composite, prix de référence, variation annuelle ou segments. Survolez la courbe pour lire les valeurs ; les indicateurs se recalculent sur la période affichée.
Chargement des données…
Explorateur interactif
Cadre macroéconomique canadien (FRED)
Taux d’intérêt, inflation, chômage, PIB, population et taux de change : les séries qui expliquent le cycle immobilier 2020–2026.
Chargement des données…
Table des matières
{''.join(toc)}
Télécharger le rapport et ses données
{files_html}
"""
return layout(course, chapters, title="Rapport — Le marché de l’habitation au Québec", body=body, active="ressources", canonical="/rapport/", body_class="report-page",
desc="Rapport de référence sur le marché de l’habitation au Québec : indices de prix MLS® 2005–2026, contexte macroéconomique, six marchés québécois, abordabilité.")
def report_section_page(course, chapters, sections, s, idx: int, body_html: str) -> str:
prev_s = sections[idx - 1] if idx > 0 else None
next_s = sections[idx + 1] if idx + 1 < len(sections) else None
side_items = "".join(f'
'
label = ("Annexe " if s.appendix else "Chapitre ") + s.num
pager = f""""""
body = f"""
{page_head(f'Le marché de l’habitation au Québec · {label}', s.title_html, '')}
{body_html}{pager}
"""
return layout(course, chapters, title=f"{label} — {s.title_text} · Rapport", body=body, active="ressources", canonical=f"/rapport/{s.num}/", body_class="report-page",
desc=f"{label} du rapport sur le marché de l’habitation au Québec : {s.title_text}.")
# -----------------------------------------------------------------------------
# Séances vidéo (enregistrements Zoom)
# -----------------------------------------------------------------------------
def _hms(t: float) -> str:
t = max(0, int(round(t)))
h, m, s = t // 3600, (t % 3600) // 60, t % 60
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
def videos_index_page(course, chapters, videos, has_report=True) -> str:
by_n = {v["n"]: v for v in videos}
cards = []
for n in range(1, 15):
ch = chapters[n - 1]
pidx, roman, ptitle, col = part_of(course, n)
v = by_n.get(n)
if v:
m = v["meta"]
cards.append(f"""
""")
total = sum(v["duration"] for v in videos)
lead = (f"{len(videos)} séance{'s' if len(videos) > 1 else ''} enregistrée{'s' if len(videos) > 1 else ''} sur Zoom ({_hms(total)} au total). Chaque enregistrement est découpé en chapitres, sous-titré et accompagné de sa transcription synchronisée : cliquez sur une phrase pour y aller dans la vidéo, cherchez un mot, reprenez où vous étiez. "
"Les enregistrements sont ajoutés après chaque séance.")
body = f"""{page_head('Séances vidéo', 'Les enregistrements des séances', lead)}
{''.join(cards)}
Enregistrements destinés aux étudiantes et étudiants inscrits au cours {e(course['code'])}. La transcription est automatique (modèle Whisper) et n’a pas été corrigée : en cas de doute, la parole du professeur prévaut ; les notes de cours restent la référence écrite.
"""
return layout(course, chapters, title="Séances vidéo", body=body, active="videos", canonical="/videos/", has_report=has_report, desc=f"Enregistrements Zoom des séances du cours {course['code']} avec chapitres, transcription synchronisée et sous-titres.")
def video_page(course, chapters, v, videos, has_report=True) -> str:
n, nn, m = v["n"], v["nn"], v["meta"]
pidx, roman, ptitle, col = part_of(course, n)
ch = chapters[n - 1]
base = f"/videos/{nn}"
chaps = v["chapters"]
prev_v = next((x for x in reversed(videos) if x["n"] < n), None)
next_v = next((x for x in videos if x["n"] > n), None)
start_t = m.get("start_t")
# chapitres (panneau)
chap_items = []
for c in chaps:
kind = c.get("kind", "")
link = f'{icon("i-book")}' if c.get("link") else ""
chap_items.append(f'
{link}
')
# questions
q_items = "".join(f'
{e(q["q"])}
{e(q["a"])}
' for q in m.get("questions", []))
# clavardage
chat_items = "".join(f'
{len(v['storyboard'])} changements d’image détectés · cliquez pour y aller
"""
pager = f"""
"""
body = f"""
{hero}
{player}
{note}
Pour reconstruire la séance
Ce qui s’est dit
{related}
{summary}
{icon('i-star')}À retenir de la séance
{keypoints}
Déroulé
Les {len(chaps)} chapitres, minute par minute
Chaque chapitre renvoie au passage de la vidéo et, lorsqu’il existe, à la section correspondante des notes de cours.
Début
Chapitre
Durée
{rows}
Téléchargements
Emporter la séance
Fichier
{dl_rows}
Transcription automatique (Whisper large-v3-turbo) non corrigée : les noms propres, les chiffres et certains termes techniques peuvent être erronés. Enregistrement destiné aux personnes inscrites au cours ; ne pas rediffuser.
{pager}
"""
desc = f"Enregistrement Zoom de la séance {n} du cours {course['code']} ({v['title']}) : {len(chaps)} chapitres, transcription synchronisée, sous-titres, résumé et points à retenir."
return layout(course, chapters, title=f"Séance vidéo {n} — {v['title']}", body=body, active="videos", desc=desc, canonical=f"{base}/", has_report=has_report, body_attrs=f' data-part="{pidx}"',
extra_head=f'')