Compare commits

..

11 commits

Author SHA1 Message Date
Christophe Robillard
0a2f26684b fill form mode 2026-03-21 16:17:59 +01:00
Christophe Robillard
a9f72b60af can edit selected day 2026-03-21 16:16:54 +01:00
Christophe Robillard
46e6014fea update day_log 2026-03-20 19:28:54 +01:00
Christophe Robillard
d6fba7e6e8 valid mood unique per day per user 2026-03-20 19:08:26 +01:00
Christophe Robillard
560a9442a7 fix modes factory 2026-03-20 19:08:26 +01:00
Christophe Robillard
93b578c3cf create day_log 2026-03-20 19:08:10 +01:00
Christophe Robillard
5c67b91302 add username to user factory 2026-03-19 21:22:05 +01:00
Christophe Robillard
962915829f add day_log model 2026-03-19 21:22:05 +01:00
Christophe Robillard
497744d165 add image for mode 2026-03-19 19:04:16 +01:00
Christophe Robillard
2ea386e557 show legend based on custom modes 2026-03-15 19:27:36 +01:00
Christophe Robillard
820d31b3e5 replace mood mode string with mode ref 2026-03-15 19:27:25 +01:00
88 changed files with 621 additions and 3295 deletions

View file

@ -1,97 +0,0 @@
Young Serif
Copyright 2023 The Young Serif Project Authors (https://github.com/noirblancrouge/YoungSerif)
Atkinson Hyperlegible Next
Copyright 2020-2024 The Atkinson Hyperlegible Next Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M4 4 L7 24 Q7.5 28 11.5 28 L19.5 28 Q23.5 28 24 24 L27 4 Z" fill="#5F9B59" stroke="#2E2A26" stroke-width="2.5" stroke-linejoin="round"/><path d="M26.5 9 C32 9 32 20 25 21" fill="none" stroke="#2E2A26" stroke-width="3" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 318 B

View file

@ -1,48 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 214" role="img" aria-labelledby="t">
<title id="t">Trois tasses de couleur alignées sur la table, et une quatrième posée sur la Kluk, fumante.</title>
<style>
.table { fill: #D9BC94; }
.table-fil, .tasse-corps, .tasse-anse, .kluk-socle, .vapeur path {
stroke: #2E2A26; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round;
}
.tasse-anse { fill: none; stroke-width: 6; }
.tasse-corps { fill: currentColor; }
.tasse-reflet { fill: none; stroke: #fff; stroke-opacity: 0.45; stroke-width: 4; stroke-linecap: round; }
.kluk-socle { fill: #B48D5F; }
.kluk-voyant { fill: #F5F6F0; }
.vapeur path { fill: none; stroke-width: 2.5; stroke: #5A534C; }
.humeur-gris { color: #8D97A8; }
.humeur-orange { color: #EDA33D; }
.humeur-rouge { color: #D8573F; }
.humeur-vert { color: #5F9B59; }
@media (prefers-reduced-motion: no-preference) {
.vapeur path { animation: vapeur 5s ease-in-out infinite; }
.vapeur path + path { animation-delay: 2.5s; }
}
@keyframes vapeur {
0%, 100% { opacity: 0.25; transform: translateY(6px); }
50% { opacity: 0.9; transform: translateY(-6px); }
}
</style>
<defs>
<g id="tasse">
<path class="tasse-anse" d="M63 22 C86 20 88 60 60 62" />
<path class="tasse-corps" d="M3 6 L10 78 Q11 86 20 86 L48 86 Q57 86 58 78 L65 6 Z" />
<path class="tasse-reflet" d="M13 18 L17 62" />
</g>
</defs>
<rect class="table" x="0" y="200" width="580" height="14" rx="3" />
<path class="table-fil" d="M0 200 H580" />
<use href="#tasse" x="34" y="114" class="humeur-gris" />
<use href="#tasse" x="132" y="114" class="humeur-orange" />
<use href="#tasse" x="230" y="114" class="humeur-rouge" />
<g transform="translate(410 98)">
<g class="vapeur">
<path d="M22 -6 c-9 -11 9 -20 0 -31 c-9 -11 9 -20 0 -31" />
<path d="M44 -10 c-9 -11 9 -20 0 -31 c-9 -11 9 -20 0 -31" />
</g>
<use href="#tasse" x="0" y="0" class="humeur-vert" />
<rect class="kluk-socle" x="-22" y="86" width="112" height="16" rx="8" />
<circle class="kluk-voyant" cx="34" cy="94" r="2.6" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 44 36"><path d="M3 3 L6 25 Q6.5 29 10.5 29 L24.5 29 Q28.5 29 29 25 L32 3 Z M31.5 8 C39 8 39.5 21 30 22" fill="none" stroke="#B48D5F" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 262 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-3 -3 95 95"><path d="M63 22 C86 20 88 60 60 62" fill="none" stroke="#2E2A26" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 6 L10 78 Q11 86 20 86 L48 86 Q57 86 58 78 L65 6 Z" fill="#8D97A8" stroke="#2E2A26" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 18 L17 62" fill="none" stroke="#fff" stroke-opacity="0.45" stroke-width="4" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 472 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-3 -3 95 95"><path d="M63 22 C86 20 88 60 60 62" fill="none" stroke="#2E2A26" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 6 L10 78 Q11 86 20 86 L48 86 Q57 86 58 78 L65 6 Z" fill="#EDA33D" stroke="#2E2A26" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 18 L17 62" fill="none" stroke="#fff" stroke-opacity="0.45" stroke-width="4" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 472 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-3 -3 95 95"><path d="M63 22 C86 20 88 60 60 62" fill="none" stroke="#2E2A26" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 6 L10 78 Q11 86 20 86 L48 86 Q57 86 58 78 L65 6 Z" fill="#D8573F" stroke="#2E2A26" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 18 L17 62" fill="none" stroke="#fff" stroke-opacity="0.45" stroke-width="4" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 472 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-3 -3 95 95"><path d="M63 22 C86 20 88 60 60 62" fill="none" stroke="#2E2A26" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 6 L10 78 Q11 86 20 86 L48 86 Q57 86 58 78 L65 6 Z" fill="#5F9B59" stroke="#2E2A26" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 18 L17 62" fill="none" stroke="#fff" stroke-opacity="0.45" stroke-width="4" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 472 B

View file

@ -1,523 +0,0 @@
/*
* Styles propres à l'application, construits sur la charte (charte.css).
* La couleur d'un mode est choisie par l'utilisateur : elle arrive dans la variable
* --humeur, posée en style en ligne. Le reste de l'interface reste neutre
* (porcelaine, encre, chêne) pour que les humeurs soient les seules couleurs.
*
* Sommaire
* 1. Compléments à la charte (boutons, champs, navigation)
* 2. Messages flash
* 3. Carte d'humeur (illustration posée sur la Kluk)
* 4. Légende
* 5. Calendrier des humeurs
* 6. Tableau de bord
* 7. Page publique
* 8. Page d'un jour (journal)
* 9. Impression
*/
/* ---------- 1. Compléments à la charte ---------- */
/* La charte ne prépare les <button class="bouton"> que dans un .formulaire */
button.bouton,
input.bouton {
border: 0;
font: inherit;
font-weight: 700;
cursor: pointer;
}
/* Bouton d'action présenté comme un lien de la charte */
button.lien {
padding: 0;
border: 0;
background: none;
color: var(--encre);
font: inherit;
cursor: pointer;
text-decoration-line: underline;
text-decoration-color: var(--vert);
text-decoration-thickness: 0.12em;
text-underline-offset: 0.2em;
}
button.lien:hover { text-decoration-color: var(--encre); }
/* button_to enveloppe son bouton dans un formulaire : le garder en ligne */
.nav form.button_to { display: inline; }
.formulaire input[type="password"],
.formulaire input[type="text"] {
display: block;
width: 100%;
padding: 0.7rem 0.85rem;
border: 2px solid var(--bordure-champ);
border-radius: var(--arrondi-petit);
background: var(--blanc);
color: var(--encre);
font: inherit;
line-height: 1.4;
}
.formulaire input[type="password"]:focus-visible,
.formulaire input[type="text"]:focus-visible {
outline: 3px solid var(--vert);
outline-offset: 1px;
border-color: var(--encre);
}
.formulaire .formulaire-erreur { margin: 0 0 1.75rem; }
.texte-etroit { max-width: calc(30rem + 2 * var(--gouttiere)); }
.texte-etroit h1 { font-size: clamp(2.1rem, 1.7rem + 2vw, 2.75rem); }
.texte-etroit .formulaire { margin-top: 1.5rem; }
/* ---------- 2. Messages flash ---------- */
.flash {
max-width: calc(var(--colonne) + 2 * var(--gouttiere));
margin: 0 auto;
padding: 0.5rem var(--gouttiere) 0;
}
.flash-message {
margin: 0 0 0.5rem;
padding: 0.75rem 1rem;
border-left: 4px solid var(--vert);
background: var(--surface);
font-size: 1rem;
}
.flash-alerte {
border-left-color: var(--rouge);
background: var(--erreur-fond);
}
/* ---------- 3. Carte d'humeur ---------- */
/* L'illustration du mode, dessinée à l'encre sur papier blanc, posée sur le socle
de la Kluk : une pilule à la couleur de l'humeur, avec son voyant. */
.carte-humeur {
margin: 0;
}
.carte-humeur img {
width: 100%;
aspect-ratio: 1;
object-fit: contain;
background: var(--blanc);
border: 2.5px solid var(--encre);
border-radius: var(--arrondi);
}
.carte-humeur::after {
content: "";
display: block;
height: 1rem;
margin: 0.4rem 6% 0;
border: 2.5px solid var(--encre);
border-radius: var(--arrondi-pilule);
background:
radial-gradient(circle, var(--porcelaine) 0 2.5px, transparent 3px),
var(--humeur, var(--porcelaine-fonce));
}
/* ---------- 4. Légende ---------- */
.pastille {
flex: none;
display: inline-block;
width: 0.85rem;
height: 0.85rem;
border-radius: 3px;
background: var(--humeur);
}
.pastille-supposee {
background: color-mix(in srgb, var(--encre-douce) 35%, var(--porcelaine));
}
.pastille-inconnue {
background: var(--surface);
border: 1.5px dashed var(--bordure-champ);
}
.pastille-journal {
position: relative;
background: var(--porcelaine-fonce);
}
.pastille-journal::after,
.jour-journal::after {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--blanc);
box-shadow: 0 0 0 1px var(--encre);
}
.legende {
margin: 0 0 0.5rem;
font-size: 1rem;
line-height: 1.4;
}
.legende ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1.4rem;
}
.legende li {
display: flex;
align-items: center;
gap: 0.5rem;
}
.legende .legende-etats {
margin-top: 0.6rem;
color: var(--encre-douce);
font-size: 0.9375rem;
}
/* ---------- 5. Calendrier des humeurs ---------- */
/* Une colonne par semaine, une ligne par jour (du lundi au dimanche), comme sur GitHub. */
.calendrier-humeurs {
--case: 0.95rem;
--ecart: 3px;
}
@media (max-width: 30rem) {
.calendrier-humeurs { --case: 0.9rem; }
}
.annee-titre {
margin: 2.25rem 0 0.75rem;
font-size: 1.6rem;
}
.annee-mois {
display: flex;
flex-wrap: wrap;
gap: 1.25rem 1rem;
}
.mois-nom {
margin: 0 0 0.3rem;
font-family: var(--sans);
font-size: 0.875rem;
font-weight: 400;
line-height: 1.4rem;
color: var(--encre-douce);
}
.mois-jours,
.semaine-jours {
display: grid;
grid-template-rows: repeat(7, var(--case));
grid-auto-flow: column;
grid-auto-columns: var(--case);
gap: var(--ecart);
}
.semaine-jours {
grid-auto-columns: auto;
padding-top: 1.7rem;
margin-right: 0.15rem;
font-size: 0.75rem;
line-height: var(--case);
color: var(--encre-douce);
}
.jour {
position: relative;
display: block;
width: var(--case);
height: var(--case);
margin: 0;
padding: 0;
border: 0;
border-radius: 3px;
background: transparent;
font: inherit;
}
.jour-filled {
background: var(--humeur);
}
/* Mode supposé : la couleur de la veille, pâlie */
.jour-guessed {
background: color-mix(in srgb, var(--humeur) 35%, var(--porcelaine));
}
.jour-unknown {
background: var(--surface);
border: 1.5px dashed var(--bordure-champ);
}
.jour-empty {
background: var(--porcelaine-fonce);
}
.jour-future {
box-shadow: inset 0 0 0 1px var(--porcelaine-fonce);
}
button.jour { cursor: pointer; }
button.jour[aria-pressed="true"] {
outline: 2.5px solid var(--encre);
outline-offset: 2px;
border-radius: 3px;
}
button.jour:focus-visible {
outline: 3px solid var(--encre);
outline-offset: 2px;
border-radius: 3px;
}
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
button.jour { transition: transform 0.12s ease; }
button.jour:hover { transform: scale(1.3); z-index: 1; }
}
/* ---------- 6. Tableau de bord ---------- */
.tableau {
max-width: var(--largeur-entete);
margin: 0 auto;
padding: 0.5rem var(--gouttiere) 0;
}
.tableau-titre {
margin: 0.5rem 0 1.25rem;
font-size: clamp(2.1rem, 1.7rem + 2vw, 3rem);
}
.tableau-calendrier { padding-bottom: 3rem; }
.detail-date {
margin: 0;
font-size: 1.35rem;
}
.detail-date::first-letter { text-transform: uppercase; }
.detail-mode {
margin: 0.2rem 0 0;
font-weight: 700;
line-height: 1.35;
}
.detail-statut {
font-weight: 400;
color: var(--encre-douce);
}
.detail-statut:not(:empty)::before { content: "("; }
.detail-statut:not(:empty)::after { content: ")"; }
.detail-journal {
margin: 0.75rem 0 1.25rem;
white-space: pre-line;
overflow-wrap: anywhere;
}
.detail-journal-vide {
color: var(--encre-douce);
font-style: italic;
}
/* Sur mobile, le jour choisi reste en bas de l'écran pendant qu'on parcourt le calendrier */
@media (max-width: 59.99rem) {
.detail {
position: sticky;
bottom: 0;
z-index: 2;
display: grid;
grid-template-columns: 4.75rem minmax(0, 1fr);
gap: 1rem;
align-items: start;
margin: 0 calc(-1 * var(--gouttiere));
padding: 0.9rem var(--gouttiere) 1rem;
background: var(--porcelaine);
border-top: 3px solid var(--encre);
}
.detail .carte-humeur img { border-width: 2px; border-radius: var(--arrondi-petit); }
.detail .carte-humeur::after { height: 0.6rem; margin-top: 0.25rem; border-width: 2px; background: var(--humeur, var(--porcelaine-fonce)); }
.detail-date { font-size: 1.1rem; }
.detail-mode { font-size: 1rem; }
.detail-journal {
margin: 0.35rem 0 0.6rem;
font-size: 1rem;
line-height: 1.45;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
}
@media (min-width: 60rem) {
.tableau {
display: grid;
grid-template-columns: minmax(0, 1fr) 19rem;
gap: 4rem;
align-items: start;
}
.detail {
position: sticky;
top: 1.5rem;
margin-top: 1.25rem;
}
.detail-texte { margin-top: 1.5rem; }
}
/* ---------- 7. Page publique ---------- */
/* Le nom de la personne en titre. Sur ordinateur, tout tient dans le premier écran :
l'illustration à gauche, le jour choisi et les derniers mois à droite. */
.public {
max-width: var(--largeur-entete);
margin: 0 auto;
padding: 0 var(--gouttiere) 4rem;
display: grid;
gap: 1.5rem;
}
.public-nom {
margin: 0;
font-size: clamp(2.6rem, 1.8rem + 3.4vw, 4rem);
line-height: 1.05;
overflow-wrap: anywhere;
}
.carte-humeur-grande {
width: 100%;
max-width: 22rem;
}
.public-date {
margin: 0;
font-size: 1.25rem;
}
.public-date::first-letter { text-transform: uppercase; }
.public-mode {
margin: 0.15rem 0 0.5rem;
font-family: var(--serif);
font-size: clamp(2rem, 1.6rem + 1.6vw, 2.75rem);
line-height: 1.15;
text-wrap: balance;
}
.public-depuis {
margin: 0;
font-size: 1.1rem;
}
.public-autre-jour .detail-statut:not(:empty) { margin-right: 0.75rem; }
.public-historique h2 {
margin: 2rem 0 0.75rem;
font-size: 1.35rem;
}
.public-historique .annee-mois { margin-top: 1rem; }
@media (min-width: 52rem) {
.public {
grid-template-columns: min(19rem, 42vh) minmax(0, 1fr);
column-gap: 3.5rem;
row-gap: 1.25rem;
align-items: start;
}
.public-nom { grid-column: 1 / -1; }
.carte-humeur-grande { max-width: none; margin-top: 0.4rem; }
}
/* Changement de jour : bref fondu du détail */
@media (prefers-reduced-motion: no-preference) {
.apparition { animation: apparition 0.35s ease-out; }
}
@keyframes apparition {
from { opacity: 0.1; }
to { opacity: 1; }
}
/* ---------- 8. Page d'un jour (journal) ---------- */
.page-jour {
max-width: 56rem;
margin: 0 auto;
padding: 0.5rem var(--gouttiere) 5rem;
}
.retour {
margin: 0;
font-size: 1rem;
}
.page-jour-titre {
margin: 0.5rem 0 1.5rem;
font-size: clamp(2rem, 1.6rem + 2vw, 2.75rem);
}
.page-jour .carte-humeur {
max-width: 11rem;
}
.page-jour .formulaire { margin-top: 1.75rem; }
.choix-modes {
list-style: none;
margin: 0.25rem 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.choix-modes li { position: relative; }
.choix-modes input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.choix-modes label {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.5rem 1rem 0.5rem 0.85rem;
border: 2px solid var(--bordure-champ);
border-radius: var(--arrondi-pilule);
background: var(--blanc);
font-size: 1rem;
line-height: 1.3;
cursor: pointer;
}
.choix-modes label:hover { border-color: var(--encre); }
.choix-modes input:checked + label {
border-color: var(--encre);
box-shadow: inset 0 0 0 1.5px var(--encre);
background: var(--surface);
}
.choix-modes input:focus-visible + label {
outline: 3px solid var(--encre);
outline-offset: 3px;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem 1.75rem;
}
@media (min-width: 48rem) {
.page-jour {
display: grid;
grid-template-columns: 17rem minmax(0, 1fr);
grid-template-areas:
"carte entete"
"carte formulaire";
column-gap: 3.5rem;
align-items: start;
}
.page-jour-entete { grid-area: entete; }
.page-jour .carte-humeur {
grid-area: carte;
max-width: none;
position: sticky;
top: 1.5rem;
margin-top: 1rem;
}
.page-jour .formulaire { grid-area: formulaire; margin-top: 0; }
}
/* ---------- 9. Impression ---------- */
@media print {
.detail { position: static; border: 0; }
.page-jour .formulaire .actions { display: none; }
}

View file

@ -0,0 +1,41 @@
// @charset "utf-8";
// Import a Google Font
// @import url('https://fonts.googleapis.com/css?family=Nunito:400,700');
// Import only what you need from Bulma
// @import "bulma/sass/utilities/_all.sass";
// @import "bulma/sass/base/_all.sass";
// @import "bulma/sass/elements/button.sass";
// @import "bulma/sass/elements/container.sass";
// @import "bulma/sass/elements/title.sass";
// @import "bulma/sass/form/_all.sass";
// @import "bulma/sass/components/navbar.sass";
// @import "bulma/sass/layout/hero.sass";
// @import "bulma/sass/layout/section.sass";
@use 'bulma/bulma';
@use 'bulma/sass/utilities/_index.scss' as v;
@use './kluk';
// Set your brand colors
// $purple: #8A4D76;
// $pink: #FA7C91;
// $brown: #757763;
// $beige-light: #D0D1CD;
// $beige-lighter: #EFF0EB;
// Update Bulma's global variables
// $family-sans-serif: "Nunito", sans-serif;
// $grey-dark: $brown;
// $grey-light: $beige-light;
// $primary: $purple;
// $link: $pink;
// $widescreen-enabled: false;
// $fullhd-enabled: false;
// Update some of Bulma's component variables
// $body-background-color: $beige-lighter;
// $control-border-width: 2px;
// $input-border-color: transparent;
// $input-shadow: none;

View file

@ -1,4 +0,0 @@
// Point d'entrée des styles, compilé par cssbundling (yarn build:css).
// La charte d'abord (variables, base, composants), puis les styles propres à l'application.
@use 'charte';
@use 'app';

View file

@ -1,531 +0,0 @@
/*
* Kluk — charte graphique, feuille de style réutilisable.
* Extraite de assets/css/style.scss (site kluk.fr), en CSS pur, sans Sass.
* Copie de charte/css/kluk.css : seuls les chemins des polices changent, ils
* pointent vers app/assets/fonts/ (Propshaft les réécrit avec l'empreinte).
* Ne pas modifier ici : les compléments propres à l’application sont dans app.css.
*
* Palette : la porcelaine du matin, l'encre du thé, le chêne de la table,
* et les quatre couleurs des tasses en accent.
*
* Sommaire
* 1. Polices
* 2. Variables (couleurs, typographies, mise en page)
* 3. Base
* 4. Liens, focus, lien d'évitement
* 5. Boutons
* 6. En-tête et navigation
* 7. Colonne de texte et titres
* 8. Illustration « tasses »
* 9. Calendrier d'humeurs
* 10. Photos
* 11. Formulaire
* 12. Pied de page
* 13. Impression
*/
/* ---------- 1. Polices ---------- */
@font-face {
font-family: "Young Serif";
src: url("/young-serif-400.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* Fichier variable : couvre toutes les graisses de 200 à 800 */
@font-face {
font-family: "Atkinson Hyperlegible Next";
src: url("/atkinson-hyperlegible-next-400.woff2") format("woff2");
font-weight: 200 800;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Atkinson Hyperlegible Next";
src: url("/atkinson-hyperlegible-next-400-italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
font-display: swap;
}
/* ---------- 2. Variables ---------- */
:root {
/* Fonds et texte */
--porcelaine: #F5F6F0;
--porcelaine-fonce: #E9ECE2;
--encre: #2E2A26;
--encre-douce: #5A534C;
--chene: #D9BC94;
--chene-fonce: #B48D5F;
/* Humeurs (les quatre tasses) */
--gris: #8D97A8;
--orange: #EDA33D;
--vert: #5F9B59;
--rouge: #D8573F;
--vert-lien: #3F7A3B;
/* Teintes utilitaires (écrites en dur dans le site d'origine) */
--encre-survol: #45403A;
--surface: #FBFCF8;
--bordure-champ: #8A8279;
--erreur-fond: #FBE9E5;
--blanc: #fff;
/* Typographies */
--serif: "Young Serif", Georgia, serif;
--sans: "Atkinson Hyperlegible Next", "Atkinson Hyperlegible", system-ui, sans-serif;
/* Mise en page */
--colonne: 36rem;
--gouttiere: 1.25rem;
--largeur-entete: 64rem;
/* Arrondis */
--arrondi-petit: 0.5rem;
--arrondi: 0.75rem;
--arrondi-pilule: 999px;
}
/* ---------- 3. Base ---------- */
*,
*::before,
*::after { box-sizing: border-box; }
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--porcelaine);
color: var(--encre);
font-family: var(--sans);
font-size: 1.1875rem;
line-height: 1.65;
font-kerning: normal;
hyphens: manual;
}
main { flex: 1; outline: none; }
img, svg { display: block; max-width: 100%; }
/* ---------- 4. Liens, focus, lien d'évitement ---------- */
a {
color: var(--encre);
text-decoration-line: underline;
text-decoration-color: var(--vert);
text-decoration-thickness: 0.12em;
text-underline-offset: 0.2em;
}
a:hover { text-decoration-color: var(--encre); }
:focus-visible {
outline: 3px solid var(--encre);
outline-offset: 3px;
border-radius: 4px;
}
.evitement {
position: absolute;
left: var(--gouttiere);
top: -4rem;
padding: 0.5rem 1rem;
background: var(--encre);
color: var(--porcelaine);
z-index: 10;
}
.evitement:focus { top: 0.75rem; }
/* ---------- 5. Boutons ---------- */
.bouton {
display: inline-block;
padding: 0.8rem 1.6rem;
border-radius: var(--arrondi-pilule);
background: var(--encre);
color: var(--porcelaine);
font-weight: 700;
line-height: 1.3;
text-decoration: none;
transition: background-color 0.15s ease;
}
.bouton:hover { background: var(--encre-survol); color: var(--porcelaine); }
.bouton:focus-visible { outline-offset: 4px; border-radius: var(--arrondi-pilule); }
.bouton-petit {
padding: 0.45rem 1rem;
font-size: 1rem;
}
/* Un bouton seul dans son paragraphe respire davantage */
p:has(> .bouton:only-child) { margin: 2rem 0; }
/* ---------- 6. En-tête et navigation ---------- */
.entete {
max-width: var(--largeur-entete);
margin: 0 auto;
padding: 1.25rem var(--gouttiere);
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem 2rem;
width: 100%;
}
.logo {
display: inline-block;
padding: 0.05rem 0.55rem 0.15rem;
border: 2.5px solid var(--encre);
border-radius: var(--arrondi-petit);
font-family: var(--sans);
font-weight: 800;
font-size: 1.35rem;
line-height: 1.2;
letter-spacing: 0.01em;
text-decoration: none;
}
.nav ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 1.35rem;
font-size: 1rem;
}
.nav a:not(.bouton) {
text-decoration-color: transparent;
}
.nav a:not(.bouton):hover,
.nav a[aria-current="page"]:not(.bouton) {
text-decoration-color: var(--vert);
}
.nav a.bouton[aria-current="page"] {
background: var(--porcelaine);
color: var(--encre);
box-shadow: inset 0 0 0 2px var(--encre);
}
/* ---------- 7. Colonne de texte et titres ---------- */
.texte {
max-width: calc(var(--colonne) + 2 * var(--gouttiere));
margin: 0 auto;
padding: 2rem var(--gouttiere) 5rem;
}
h1, h2, h3 {
font-family: var(--serif);
font-weight: 400;
line-height: 1.15;
letter-spacing: -0.005em;
text-wrap: balance;
}
h1 {
font-size: clamp(2.35rem, 1.6rem + 3.6vw, 3.75rem);
margin: 1.5rem 0 1.5rem;
}
h2 {
font-size: clamp(1.6rem, 1.35rem + 1vw, 2rem);
margin: 4.5rem 0 1.25rem;
}
p { margin: 0 0 1.15rem; }
/* Le chapeau : paragraphe entièrement en italique juste sous le titre */
h1 + p:has(> em:only-child) {
font-size: 1.3rem;
line-height: 1.55;
color: var(--encre-douce);
}
.note {
margin-top: 2.5rem;
color: var(--encre-douce);
font-size: 1.0625rem;
}
.exergue {
margin: 2.25rem 0;
padding-left: 1.25rem;
border-left: 4px solid var(--orange);
font-family: var(--serif);
font-size: 1.45rem;
line-height: 1.4;
}
code {
font-size: 0.9em;
background: var(--porcelaine-fonce);
padding: 0.05em 0.3em;
border-radius: 3px;
}
/* ---------- 8. Illustration « tasses » ---------- */
.tasses {
margin: 1.5rem 0 0;
}
/* L'illustration déborde légèrement de la colonne sur grand écran */
@media (min-width: 48rem) {
.tasses { margin-inline: -4rem; }
}
.tasses svg { width: 100%; height: auto; overflow: visible; }
.table { fill: var(--chene); }
.table-fil,
.tasse-corps,
.tasse-anse,
.kluk-socle,
.vapeur path {
stroke: var(--encre);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.tasse-anse { fill: none; stroke-width: 6; }
.tasse-corps { fill: currentColor; }
.tasse-reflet { fill: none; stroke: var(--blanc); stroke-opacity: 0.45; stroke-width: 4; stroke-linecap: round; }
.kluk-socle { fill: var(--chene-fonce); }
.kluk-voyant { fill: var(--porcelaine); }
.vapeur path { fill: none; stroke-width: 2.5; stroke: var(--encre-douce); }
.humeur-gris { color: var(--gris); }
.humeur-orange { color: var(--orange); }
.humeur-rouge { color: var(--rouge); }
.humeur-vert { color: var(--vert); }
@media (prefers-reduced-motion: no-preference) {
.vapeur path {
animation: vapeur 5s ease-in-out infinite;
}
.vapeur path + path { animation-delay: 2.5s; }
}
@keyframes vapeur {
0%, 100% { opacity: 0.25; transform: translateY(6px); }
50% { opacity: 0.9; transform: translateY(-6px); }
}
/* ---------- 9. Calendrier d'humeurs (décoratif) ---------- */
.calendrier {
display: grid;
grid-template-rows: repeat(7, auto);
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: 4px;
margin: 2.25rem 0 1rem;
padding: 1.25rem;
background: var(--surface);
border-radius: var(--arrondi);
box-shadow: inset 0 0 0 1px var(--porcelaine-fonce);
}
.jour {
aspect-ratio: 1;
border-radius: 2px;
}
.jour-g { background: var(--gris); }
.jour-o { background: var(--orange); }
.jour-v { background: var(--vert); }
.jour-r { background: var(--rouge); }
@media (max-width: 30rem) {
.calendrier { gap: 3px; padding: 0.9rem; }
}
/* ---------- 10. Photos ---------- */
.photo {
margin: 2rem 0;
}
.photo img {
width: 100%;
height: auto;
border-radius: var(--arrondi);
}
.photo figcaption {
margin-top: 0.6rem;
font-size: 0.95rem;
color: var(--encre-douce);
}
.photo-attente {
display: flex;
align-items: center;
gap: 1.25rem;
aspect-ratio: 3 / 2;
padding: 1.5rem;
border: 2px dashed var(--chene-fonce);
border-radius: var(--arrondi);
background: var(--porcelaine-fonce);
color: var(--encre-douce);
font-size: 1rem;
line-height: 1.5;
}
.photo-attente svg {
flex: none;
width: 2.75rem;
fill: none;
stroke: var(--chene-fonce);
stroke-width: 2.5;
stroke-linejoin: round;
stroke-linecap: round;
}
.photo-attente p { margin: 0; }
.photo-attente strong { color: var(--encre); }
/* ---------- 11. Formulaire ---------- */
.formulaire {
margin: 2.5rem 0 1rem;
padding: 1.75rem 1.25rem;
background: var(--surface);
border-radius: var(--arrondi);
box-shadow: inset 0 0 0 1px var(--porcelaine-fonce);
}
@media (min-width: 40rem) {
.formulaire { padding: 2.25rem 2.5rem; }
}
.champ {
margin: 0 0 1.75rem;
padding: 0;
border: 0;
min-width: 0;
}
.champ > label,
.champ > legend {
display: block;
padding: 0;
margin-bottom: 0.5rem;
font-weight: 700;
}
.precision {
font-weight: 400;
color: var(--encre-douce);
}
.formulaire input[type="email"],
.formulaire textarea {
display: block;
width: 100%;
padding: 0.7rem 0.85rem;
border: 2px solid var(--bordure-champ);
border-radius: var(--arrondi-petit);
background: var(--blanc);
color: var(--encre);
font: inherit;
line-height: 1.4;
}
.formulaire textarea { resize: vertical; min-height: 8rem; }
.formulaire input[type="email"]:focus-visible,
.formulaire textarea:focus-visible {
outline: 3px solid var(--vert);
outline-offset: 1px;
border-color: var(--encre);
}
.choix {
list-style: none;
margin: 0;
padding: 0;
}
.choix li {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.35rem 0;
}
.choix input {
flex: none;
width: 1.35rem;
height: 1.35rem;
margin: 0.2rem 0 0;
accent-color: var(--vert-lien);
cursor: pointer;
}
.choix label { cursor: pointer; }
/* Champ piège anti-robots, invisible */
.piege {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.formulaire-erreur {
padding: 0.75rem 1rem;
border-left: 4px solid var(--rouge);
background: var(--erreur-fond);
font-size: 1rem;
}
.formulaire .bouton {
border: 0;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.formulaire .bouton:disabled { opacity: 0.6; cursor: progress; }
.formulaire-secours {
margin: 1rem 0 0;
font-size: 1rem;
color: var(--encre-douce);
}
/* ---------- 12. Pied de page : la table en bois ---------- */
.pied {
background: var(--chene);
border-top: 3px solid var(--encre);
font-size: 1rem;
}
.pied-interieur {
max-width: calc(var(--colonne) + 2 * var(--gouttiere));
margin: 0 auto;
padding: 2rem var(--gouttiere) 2.5rem;
}
.pied-phrase {
font-family: var(--serif);
font-size: 1.15rem;
margin: 0 0 1rem;
}
.pied-liens {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem 2rem;
}
.pied a { text-decoration-color: var(--encre); text-decoration-thickness: 1px; }
.pied a:hover { text-decoration-thickness: 2px; }
/* ---------- 13. Impression ---------- */
@media print {
.entete .nav, .pied, .tasses, .calendrier { display: none; }
body { background: var(--blanc); }
}

View file

@ -0,0 +1,66 @@
/*
* This is a manifest file that'll be compiled into application.css.
*
* With Propshaft, assets are served efficiently without preprocessing steps. You can still include
* application-wide styles in this file, but keep in mind that CSS precedence will follow the standard
* cascading order, meaning styles declared later in the document or manifest will override earlier ones,
* depending on specificity.
*
* Consider organizing styles into separate files for maintainability.
*/
body {
margin: 0px;
font-family: "Sour Gummy", sans-serif;
font-weight: 350;
font-style: normal;
}
main {
height: 100vh;
}
#navbar-top {
position: sticky;
top: 0;
}
.tracker {
align-content: center;
}
.logs {
height: 60vh;
overflow: scroll;
}
.current-day {
background-color: white;
position: sticky;
top: 64px;
align-content: center;
}
.bar {
width: 10px;
height: 10px;
}
.day {
border: 1px;
margin: 4px;
width: 15px;
height: 15px;
}
.selected-day {
border: 2px double white;
margin: 4px;
width: 15px;
height: 15px;
}
.info {
margin: 30px;
}

View file

@ -1,7 +0,0 @@
# Page de démonstration des composants de la charte graphique (développement seulement).
class CharteController < ApplicationController
allow_unauthenticated_access
def show
end
end

View file

@ -11,7 +11,7 @@ class DayLogsController < ApplicationController
if @day_log.save if @day_log.save
handle_mood(@day_log.day, params[:day_log][:mode_id]) handle_mood(@day_log.day, params[:day_log][:mode_id])
redirect_to dashboard_path(day: @day_log.day) redirect_to root_path
else else
render :new, status: :unprocessable_content render :new, status: :unprocessable_content
end end
@ -20,7 +20,7 @@ class DayLogsController < ApplicationController
def update def update
if @day_log.update(day_log_params) if @day_log.update(day_log_params)
handle_mood(@day_log.day, params[:day_log][:mode_id]) handle_mood(@day_log.day, params[:day_log][:mode_id])
redirect_to dashboard_path(day: @day_log.day) redirect_to dashboard_path
else else
render :edit, status: :unprocessable_content render :edit, status: :unprocessable_content
end end

View file

@ -4,7 +4,7 @@ class HomeController < ApplicationController
def index def index
@user = User.find_by(username: request.subdomain) @user = User.find_by(username: request.subdomain)
if @user&.public? if @user&.public?
render "moods/public" render "moods/index"
else else
redirect_to dashboard_path redirect_to dashboard_path
end end

View file

@ -9,9 +9,9 @@ class InvitationsController < ApplicationController
password_params = params.expect(user: [ :password, :password_confirmation ]) password_params = params.expect(user: [ :password, :password_confirmation ])
if @user.update(password_params.merge(invitation_accepted_at: Time.current)) if @user.update(password_params.merge(invitation_accepted_at: Time.current))
redirect_to root_path, notice: "Compte activé. Bienvenue !" redirect_to root_path, notice: "Account activated!"
else else
redirect_to invitation_path(params[:token]), alert: "Les deux mots de passe ne sont pas identiques." redirect_to invitation_path(params[:token]), alert: "Passwords did not match."
end end
end end
@ -20,6 +20,6 @@ class InvitationsController < ApplicationController
def set_user_by_token def set_user_by_token
@user = User.find_by_invitation_token(params[:token]) @user = User.find_by_invitation_token(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to root_path, alert: "Ce lien d’invitation n’est plus valable." redirect_to root_path, alert: "Invitation link is invalid or has expired."
end end
end end

View file

@ -1,6 +1,5 @@
class MoodsController < ApplicationController class MoodsController < ApplicationController
def index def index
@user = Current.user @user = Current.user
redirect_to dashboard_path(day: Date.today) unless params[:day]
end end
end end

View file

@ -10,7 +10,7 @@ class PasswordsController < ApplicationController
PasswordsMailer.reset(user).deliver_later PasswordsMailer.reset(user).deliver_later
end end
redirect_to new_session_path, notice: "Si un compte existe avec cette adresse, un lien pour changer de mot de passe vient de lui être envoyé." redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
end end
def edit def edit
@ -18,9 +18,9 @@ class PasswordsController < ApplicationController
def update def update
if @user.update(params.permit(:password, :password_confirmation)) if @user.update(params.permit(:password, :password_confirmation))
redirect_to new_session_path, notice: "Mot de passe changé. Vous pouvez vous connecter." redirect_to new_session_path, notice: "Password has been reset."
else else
redirect_to edit_password_path(params[:token]), alert: "Les deux mots de passe ne sont pas identiques." redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
end end
end end
@ -28,6 +28,6 @@ class PasswordsController < ApplicationController
def set_user_by_token def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token]) @user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Ce lien n’est plus valable. Demandez-en un nouveau." redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end end
end end

View file

@ -1,6 +1,6 @@
class SessionsController < ApplicationController class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ] allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_url, alert: "Trop de tentatives. Réessayez dans quelques minutes." } rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_url, alert: "Try again later." }
def new def new
end end
@ -10,7 +10,7 @@ class SessionsController < ApplicationController
start_new_session_for user start_new_session_for user
redirect_to after_authentication_url redirect_to after_authentication_url
else else
redirect_to new_session_path, alert: "Adresse mail ou mot de passe incorrect." redirect_to new_session_path, alert: "Try another email address or password."
end end
end end

View file

@ -1,8 +1,2 @@
module ApplicationHelper module ApplicationHelper
# Lien de navigation de l'en-tête : marqué aria-current="page" sur la page courante,
# ce qui déclenche le soulignement vert (ou l'inversion du bouton) de la charte.
def lien_nav(nom, chemin, **options)
options[:"aria-current"] = "page" if current_page?(chemin)
link_to nom, chemin, **options
end
end end

View file

@ -1,86 +1,15 @@
module MoodsHelper module MoodsHelper
# État d'un jour du calendrier :
# :filled une tasse a été posée ce jour-là
# :guessed pas de tasse posée, on suppose que le mode de la veille continue
# :unknown pas de tasse posée, et l'utilisateur ne veut pas de supposition
# :empty avant le premier enregistrement
# :future après aujourd'hui
def day_status(mood, user)
return :future if mood[:recorded_at].to_date > Date.current
return :filled if mood[:mode].present?
return :guessed if mood[:guess].present? && user.guess?
return :unknown if mood[:guess].present? && !user.guess?
:empty
end
def mode_for(mood, user) def mode_for(mood, user)
case day_status(mood, user) if user.guess?
when :filled then mood[:mode] mood[:mode] || mood[:guess] || { label: "unknown", color: "white", image_url: "unknown.jpg" }
when :guessed then mood[:guess]
end
end
# Les années du calendrier, la plus récente d'abord ; les mois dans l'ordre à l'intérieur.
def history_by_year(history)
history.group_by { |month| month[:month].year }.sort_by { |year, _| -year }
end
def find_day(history, date)
history.each do |month|
month[:weeks].each do |week|
week.each { |mood| return mood if mood[:recorded_at].to_date == date }
end
end
nil
end
def date_longue(date)
l(date, format: "%A %-d %B %Y")
end
# « de robi », mais « d’Anne » : élision devant une voyelle ou un h muet
def de_nom(nom)
nom.match?(/\A[aeiouyhàâéèêëîïôöùûü]/i) ? "d’#{nom}" : "de #{nom}"
end
def libelle_statut(status)
case status
when :guessed then "supposé"
when :unknown then "pas de tasse posée"
end
end
# Un jour du calendrier : un bouton si on peut le choisir, sinon une simple case.
# Le journal n'est exposé que s'il est demandé (jamais sur la page publique).
def case_jour(mood, user, interactive:, selected: false, journal: false)
date = mood[:recorded_at].to_date
status = day_status(mood, user)
mode = mode_for(mood, user)
day_log = mood[:day_log] if journal
description = [ date_longue(date), mode&.label, libelle_statut(status), ("journal écrit" if day_log) ].compact.join(", ")
classes = [ "jour", "jour-#{status}" ]
classes << "jour-journal" if day_log
classes << "jour-aujourdhui" if date == Date.current
options = { class: classes, style: ("--humeur: #{mode.color}" if mode), title: description }
if interactive && status != :future
tag.button(type: "button", **options,
tabindex: selected ? 0 : -1,
"aria-label": description,
"aria-pressed": selected.to_s,
data: {
day: date.iso8601,
date: date_longue(date),
mode: mode&.label,
statut: libelle_statut(status),
couleur: mode&.color,
image: (mode || Mode.new).image_url,
info: day_log&.info,
action: "calendrier#choisir keydown->calendrier#naviguer"
})
else else
tag.span(**options, "aria-hidden": true) mood[:mode] || { label: "unknown", color: "white", image_url: "unknown.jpg" }
end end
end end
def style_for_mode(mode)
style = "background-color: #{mode[:color]};"
style += " border: 2px double grey;" if mode[:label] == "unknown"
style
end
end end

View file

@ -1,98 +0,0 @@
import { Controller } from "@hotwired/stimulus"
// Calendrier des humeurs : choisir un jour affiche son détail (illustration, mode, et
// sur le tableau de bord, le journal). Sert au tableau de bord comme à la page publique :
// chaque cible est facultative.
// Au clavier, les flèches se déplacent d'un jour (haut, bas) ou d'une semaine (gauche, droite).
export default class extends Controller {
static targets = [ "carte", "image", "date", "mode", "statut", "depuis", "retour", "journal", "lien", "anime" ]
static values = { aujourdhui: String, aujourdhuiDate: String, aujourdhuiMode: String, aujourdhuiImage: String, aujourdhuiCouleur: String }
connect() {
const selectionne = this.element.querySelector(".jour[aria-pressed='true']")
if (selectionne && new URLSearchParams(window.location.search).has("day")) {
selectionne.scrollIntoView({ block: "nearest" })
}
}
choisir(event) {
this.selectionner(event.currentTarget)
}
revenir() {
const aujourdhui = this.element.querySelector(`button.jour[data-day="${this.aujourdhuiValue}"]`)
if (aujourdhui) this.selectionner(aujourdhui)
}
naviguer(event) {
const decalages = { ArrowUp: -1, ArrowDown: 1, ArrowLeft: -7, ArrowRight: 7 }
const decalage = decalages[event.key]
if (!decalage) return
const date = new Date(`${event.currentTarget.dataset.day}T12:00:00`)
date.setDate(date.getDate() + decalage)
const cible = this.element.querySelector(`button.jour[data-day="${date.toISOString().slice(0, 10)}"]`)
if (!cible) return
event.preventDefault()
this.selectionner(cible)
cible.focus()
}
selectionner(jour) {
const precedent = this.element.querySelector(".jour[aria-pressed='true']")
if (precedent) {
precedent.setAttribute("aria-pressed", "false")
precedent.tabIndex = -1
}
jour.setAttribute("aria-pressed", "true")
jour.tabIndex = 0
this.animer()
// Sur la page publique, aujourd'hui montre la dernière tasse posée et depuis quand
if (jour.dataset.day === this.aujourdhuiValue && this.hasDepuisTarget) {
this.afficherAujourdhui()
return
}
const { day, date, mode, statut, couleur, image, info } = jour.dataset
this.carteTarget.style.setProperty("--humeur", couleur || "")
this.imageTarget.src = image
this.modeTarget.textContent = mode || "Pas de tasse posée"
this.statutTarget.textContent = mode ? (statut || "") : ""
this.dateTarget.textContent = date
if (this.hasDepuisTarget) this.depuisTarget.hidden = true
if (this.hasRetourTarget) this.retourTarget.hidden = false
if (this.hasJournalTarget) {
this.journalTarget.textContent = info || this.journalTarget.dataset.vide
this.journalTarget.classList.toggle("detail-journal-vide", !info)
}
if (this.hasLienTarget) {
this.lienTarget.href = `/day_logs/edit?day=${day}`
this.lienTarget.textContent = info ? "Modifier le journal" : "Écrire dans le journal"
}
history.replaceState({}, "", `?day=${day}`)
}
afficherAujourdhui() {
this.carteTarget.style.setProperty("--humeur", this.aujourdhuiCouleurValue)
this.imageTarget.src = this.aujourdhuiImageValue
this.modeTarget.textContent = this.aujourdhuiModeValue
this.dateTarget.textContent = this.aujourdhuiDateValue
this.depuisTarget.hidden = false
this.retourTarget.hidden = true
history.replaceState({}, "", window.location.pathname)
}
// Un bref fondu signale que le détail a changé, même quand le mode est le même
animer() {
this.animeTargets.forEach((element) => {
element.classList.remove("apparition")
void element.offsetWidth
element.classList.add("apparition")
})
}
}

View file

@ -1,12 +0,0 @@
import { Controller } from "@hotwired/stimulus"
// Formulaire du journal : l'illustration suit le mode choisi.
export default class extends Controller {
static targets = [ "carte", "image" ]
changer(event) {
const { image, couleur } = event.currentTarget.dataset
this.imageTarget.src = image
this.carteTarget.style.setProperty("--humeur", couleur)
}
}

View file

@ -0,0 +1,11 @@
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = [ 'burger', 'navbar' ];
toggle(event) {
[ this.burgerTarget, this.navbarTarget ].forEach((target) => {
target.classList.toggle("is-active");
});
}
}

View file

@ -0,0 +1,28 @@
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = [ "image", "modeDay", "dayLogLink" ];
updateDayInfo(event) {
const image = this.imageTarget;
const modeDay = this.modeDayTarget;
const day = event.target.dataset.day;
const modeDayContent = day + ' : ' + event.target.dataset.mode;
image.src = event.target.dataset.image;
modeDay.textContent = modeDayContent;
event.target.className = "selected-day " + event.target.dataset.mode;
this.dayLogLinkTarget.href = `/day_logs/edit?day=${day}`
if (this.lastTarget) {
this.lastTarget.className = "day " + this.lastTarget.dataset.mode;
}
this.lastTarget = event.target;
}
connect() {
this.lastTarget = null;
window.location = "#end";
}
}

View file

@ -1,8 +1,4 @@
class Mode < ApplicationRecord class Mode < ApplicationRecord
belongs_to :user belongs_to :user
has_one_attached :image has_one_attached :image
def image_url
image.attached? ? Rails.application.routes.url_helpers.rails_blob_path(image, only_path: true) : ActionController::Base.helpers.asset_path("unknown.jpg")
end
end end

View file

@ -1,4 +1,3 @@
class RfidTag < ApplicationRecord class RfidTag < ApplicationRecord
belongs_to :user, required: false belongs_to :user, required: false
belongs_to :mode, required: false
end end

View file

@ -33,25 +33,10 @@ class User < ApplicationRecord
end end
def history def history
MoodCalendarService.generate_calendar(self) MoodCalendarService.generate_calendar(moods)
end end
def current_mode def current_mode
self.moods.last&.mode || "croisiere" self.moods.last&.mode || "croisiere"
end end
# Le jour où l'humeur actuelle a commencé : le plus ancien des derniers
# enregistrements consécutifs dans le mode actuel.
def current_mode_since
since = nil
moods.reorder(recorded_at: :desc).pluck(:mode_id, :recorded_at).each do |mode_id, recorded_at|
break if since && mode_id != since[:mode_id]
since = { mode_id: mode_id, day: recorded_at.to_date }
end
since&.dig(:day)
end
def current_day_log
self.day_logs.find_by(day: Date.today)
end
end end

View file

@ -1,10 +1,26 @@
# app/services/mood_calendar_service.rb
class MoodCalendarService class MoodCalendarService
def self.generate_calendar(user, start_date: nil, end_date: nil) def self.generate_calendar(moods, start_date: nil, end_date: Date.current)
data = user.moods.includes(:mode) # Preload modes with their image attachments
.order(:recorded_at) mode_ids = moods.joins(:mode).pluck("modes.id").uniq
.map { |mood| { mode: mood.mode, recorded_at: mood.recorded_at } } modes_by_id = Mode.where(id: mode_ids)
.with_attached_image
.index_by(&:id)
day_logs_by_date = user.day_logs.index_by(&:day) data = moods.joins(:mode)
.order(:recorded_at)
.pluck(:recorded_at, "modes.label", "modes.color", "modes.id")
.map do |recorded_at, label, color, mode_id|
mode = modes_by_id[mode_id]
{
mode: {
label: label,
color: color,
image_url: mode&.image&.attached? ? Rails.application.routes.url_helpers.rails_blob_url(mode.image, only_path: true) : nil
},
recorded_at: recorded_at
}
end # Convertir la relation ActiveRecord en tableau de hash
if data.empty? if data.empty?
start_date = Date.current start_date = Date.current
@ -12,35 +28,36 @@ class MoodCalendarService
start_date ||= data.first[:recorded_at].to_date start_date ||= data.first[:recorded_at].to_date
end end
start_date = start_date.to_date if end_date < (start_date + 5.months)
end_date = end_date ? end_date.to_date : [ Date.current, start_date + 5.months ].max
=begin
if end_date.nil?
end_date = start_date + 5.months end_date = start_date + 5.months
else
end_date = end_date.to_date
end end
=end
# Convertir en Date si ce sont des DateTime ou Time
start_date = start_date.to_date
end_date = end_date&.to_date
# Grouper par jour et garder le plus récent pour chaque jour
data_by_date = data.group_by { |d| d[:recorded_at].to_date } data_by_date = data.group_by { |d| d[:recorded_at].to_date }
.transform_values { |entries| entries.max_by { |e| e[:recorded_at] } } .transform_values { |entries| entries.max_by { |e| e[:recorded_at] } }
# Trouver le dernier mood avant start_date pour initialiser le guess
last_mode = data.select { |d| d[:recorded_at].to_date < start_date } last_mode = data.select { |d| d[:recorded_at].to_date < start_date }
.max_by { |d| d[:recorded_at] } .max_by { |d| d[:recorded_at] }
&.[](:mode) &.[](:mode)
# Générer le tableau complet avec tous les jours
complete_data = (start_date..end_date).map do |date| complete_data = (start_date..end_date).map do |date|
day_log = day_logs_by_date[date]
if data_by_date[date] if data_by_date[date]
last_mode = data_by_date[date][:mode] last_mode = data_by_date[date][:mode]
data_by_date[date].merge(day_log: day_log) data_by_date[date]
else else
{ mode: nil, recorded_at: date.to_datetime, guess: last_mode, day_log: day_log } { mode: nil, recorded_at: date.to_datetime, guess: last_mode }
end end
end end
# Regrouper par mois avec semaines commençant au premier lundi
complete_data.group_by { |d| d[:recorded_at].to_date.beginning_of_month } complete_data.group_by { |d| d[:recorded_at].to_date.beginning_of_month }
.map do |month_start, _| .map do |month_start, month_data|
first_monday = month_start first_monday = month_start
first_monday = first_monday.next_occurring(:monday) unless first_monday.monday? first_monday = first_monday.next_occurring(:monday) unless first_monday.monday?
@ -53,7 +70,7 @@ class MoodCalendarService
data_hash = complete_data.index_by { |d| d[:recorded_at].to_date } data_hash = complete_data.index_by { |d| d[:recorded_at].to_date }
all_days = (first_monday..month_end).map do |date| all_days = (first_monday..month_end).map do |date|
data_hash[date] || { mode: nil, recorded_at: date.to_datetime, guess: nil, day_log: nil } data_hash[date] || { mode: nil, recorded_at: date.to_datetime, guess: nil }
end end
weeks = all_days.group_by { |d| d[:recorded_at].to_date.beginning_of_week(:monday) } weeks = all_days.group_by { |d| d[:recorded_at].to_date.beginning_of_week(:monday) }
@ -66,4 +83,4 @@ class MoodCalendarService
} }
end end
end end
end end#

View file

@ -1,13 +0,0 @@
<header class="entete">
<%= link_to "kluk", root_path, class: "logo", "aria-label": "Kluk, retour à l'accueil" %>
<nav class="nav" aria-label="Navigation principale">
<ul>
<% if authenticated? %>
<li><%= lien_nav "Mes humeurs", dashboard_path %></li>
<li><%= button_to "Se déconnecter", session_path, method: :delete, class: "bouton bouton-petit" %></li>
<% else %>
<li><%= lien_nav "Se connecter", new_session_path, class: "bouton bouton-petit" %></li>
<% end %>
</ul>
</nav>
</header>

View file

@ -1,10 +0,0 @@
<% if flash[:alert].present? || flash[:notice].present? %>
<div class="flash">
<% if flash[:alert].present? %>
<p class="flash-message flash-alerte" role="alert"><%= flash[:alert] %></p>
<% end %>
<% if flash[:notice].present? %>
<p class="flash-message" role="status"><%= flash[:notice] %></p>
<% end %>
</div>
<% end %>

View file

@ -0,0 +1,27 @@
<nav id="navbar-top" class="navbar has-background-primary" role="navigation" aria-label="main navigation" data-controller="menu">
<div class="navbar-brand">
<div class="navbar-item is-size-5">Comment ça </div>
<div class="navbar-item is-size-3"><strong>KLUK</strong></div>
<div class="navbar-item is-size-5">aujourd'hui ?</div>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-menu-target="burger" data-action="menu#toggle">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div class="navbar-menu" data-menu-target="navbar">
<div class="navbar-end">
<% if authenticated? %>
<div class="navbar-item">
<%= button_to "Se déconnecter", session_path, method: :delete, class: "button is-light" %>
</div>
<% else %>
<div class="navbar-item">
<%= link_to "Se connecter", new_session_path, class: "button is-light" %>
</div>
<% end %>
</div>
</div>
</nav>

View file

@ -1,9 +0,0 @@
<footer class="pied">
<div class="pied-interieur">
<p class="pied-phrase">Comment ça Kluk aujourd’hui&#8239;?</p>
<ul class="pied-liens">
<li><%= link_to "Découvrir la Kluk", "https://www.kluk.fr/" %></li>
<li><%= link_to "Mentions légales", "https://www.kluk.fr/mentions-legales/" %></li>
</ul>
</div>
</footer>

View file

@ -1,5 +0,0 @@
<%# Bouton de la charte (pilule encre). Pour un bouton d'envoi : f.submit "…", class: "bouton".
render "charte/bouton", texte: "Je veux ma Kluk", href: dashboard_path
render "charte/bouton", texte: "Voir", href: "#", petit: true, data: { turbo_frame: "x" } %>
<%# locals: (texte:, href:, petit: false, **attributs) -%>
<%= link_to texte, href, class: ["bouton", ("bouton-petit" if petit)], **attributs %>

View file

@ -1,10 +0,0 @@
<%# Calendrier d'humeurs décoratif : une case par jour, colonnes de 7 jours (une semaine par colonne).
jours : suite de lettres g (gris), o (orange), v (vert), r (rouge), en chaîne ou en tableau.
render "charte/calendrier", jours: "vvovvvg vvvvovv ovvrvvv" %>
<%# locals: (jours:) -%>
<% jours = jours.is_a?(String) ? jours.delete(" ").chars : Array(jours) %>
<div class="calendrier" aria-hidden="true">
<% jours.each do |jour| %>
<span class="jour jour-<%= jour %>"></span>
<% end %>
</div>

View file

@ -1,12 +0,0 @@
<%# Champ de formulaire de la charte (dans un form de classe "formulaire") :
libellé en gras au-dessus, précision facultative en encre douce. Le bloc donne la saisie.
render("charte/champ", form: f, attribut: :email_address, libelle: "Votre adresse mail", precision: "(obligatoire)") { f.email_field :email_address, required: true }
(en ERB, avec « do » / « end » et la saisie dans le bloc) %>
<%# locals: (form:, attribut:, libelle:, precision: nil) -%>
<div class="champ">
<%= form.label attribut do %>
<%= libelle %>
<% if precision %><span class="precision"><%= precision %></span><% end %>
<% end %>
<%= yield %>
</div>

View file

@ -1,4 +0,0 @@
<%# Phrase mise en valeur, en serif, avec un trait orange.
render "charte/exergue", texte: "Une phrase à mettre en valeur." %>
<%# locals: (texte:) -%>
<%= tag.p texte, class: "exergue" %>

View file

@ -1,8 +0,0 @@
<%# Message d'erreur de formulaire (trait rouge sur fond rosé). Rien n'est affiché sans message.
render "charte/formulaire_erreur", messages: @day_log.errors.full_messages
render "charte/formulaire_erreur", messages: flash[:alert] %>
<%# locals: (messages:) -%>
<% messages = Array(messages).compact_blank %>
<% if messages.any? %>
<p class="formulaire-erreur" role="alert"><%= safe_join(messages, tag.br) %></p>
<% end %>

View file

@ -1,4 +0,0 @@
<%# Précision discrète, en fin de page.
render "charte/note", texte: "Une précision." %>
<%# locals: (texte:) -%>
<%= tag.p texte, class: "note" %>

View file

@ -1,10 +0,0 @@
<%# Photo arrondie avec légende facultative. Toujours un alt descriptif, et width/height
pour éviter les sauts de mise en page (format du site : 1200 × 900).
render "charte/photo", src: "croisiere.jpg", alt: "…", width: 1200, height: 900, legende: "…" %>
<%# locals: (src:, alt:, width: nil, height: nil, legende: nil) -%>
<figure class="photo">
<%= image_tag src, alt: alt, width: width, height: height, loading: "lazy", decoding: "async" %>
<% if legende %>
<figcaption><%= legende %></figcaption>
<% end %>
</figure>

View file

@ -1,12 +0,0 @@
<%# Emplacement de photo, en attendant l'image : cadre 3:2 en pointillés chêne.
render "charte/photo_attente", description: "La Kluk posée sur la table du petit-déjeuner." %>
<%# locals: (description:, titre: nil, legende: nil) -%>
<figure class="photo">
<div class="photo-attente">
<svg viewBox="0 0 40 32" aria-hidden="true" focusable="false"><path d="M3 3 L6 25 Q6.5 29 10.5 29 L24.5 29 Q28.5 29 29 25 L32 3 Z M31.5 8 C39 8 39.5 21 30 22" /></svg>
<p><strong><%= titre || "Photo à venir" %></strong><br><%= description %></p>
</div>
<% if legende %>
<figcaption><%= legende %></figcaption>
<% end %>
</figure>

View file

@ -1,30 +0,0 @@
<%# Illustration « table et tasses », en SVG dans la page pour suivre les variables CSS.
Une seule par page (identifiants #tasse et #tasses-titre).
render "charte/tasses" %>
<%# locals: () -%>
<figure class="tasses">
<svg viewBox="0 0 580 214" role="img" aria-labelledby="tasses-titre">
<title id="tasses-titre">Illustration : trois tasses de couleur alignées sur la table, et une quatrième posée sur la Kluk, fumante.</title>
<defs>
<g id="tasse">
<path class="tasse-anse" d="M63 22 C86 20 88 60 60 62" />
<path class="tasse-corps" d="M3 6 L10 78 Q11 86 20 86 L48 86 Q57 86 58 78 L65 6 Z" />
<path class="tasse-reflet" d="M13 18 L17 62" />
</g>
</defs>
<rect class="table" x="0" y="200" width="580" height="14" rx="3" />
<path class="table-fil" d="M0 200 H580" />
<use href="#tasse" x="34" y="114" class="humeur-gris" />
<use href="#tasse" x="132" y="114" class="humeur-orange" />
<use href="#tasse" x="230" y="114" class="humeur-rouge" />
<g class="kluk-illu" transform="translate(410 98)">
<g class="vapeur" aria-hidden="true">
<path d="M22 -6 c-9 -11 9 -20 0 -31 c-9 -11 9 -20 0 -31" />
<path d="M44 -10 c-9 -11 9 -20 0 -31 c-9 -11 9 -20 0 -31" />
</g>
<use href="#tasse" x="0" y="0" class="humeur-vert" />
<rect class="kluk-socle" x="-22" y="86" width="112" height="16" rx="8" />
<circle class="kluk-voyant" cx="34" cy="94" r="2.6" />
</g>
</svg>
</figure>

View file

@ -1,38 +0,0 @@
<% content_for :title, "Charte Kluk" %>
<article class="texte">
<h1>Charte graphique Kluk</h1>
<p><em>Les composants de la charte, rendus par les partials de app/views/charte.</em></p>
<p>Texte courant en Atkinson Hyperlegible Next, avec <a href="#">un lien souligné de vert</a>, du <strong>gras</strong>, de l’<em>italique</em> et du <code>code</code>.</p>
<p><%= render "charte/bouton", texte: "Bouton principal", href: "#" %></p>
<p><%= render "charte/bouton", texte: "Petit bouton", href: "#", petit: true %></p>
<%= render "charte/tasses" %>
<h2>Intertitre de section</h2>
<p>Les intertitres ménagent un grand espace au-dessus d’eux pour découper la page en respirations.</p>
<%= render "charte/exergue", texte: "Une phrase mise en exergue, en serif, soulignée d’un trait orange." %>
<%= render "charte/photo", src: "croisiere.jpg", alt: "Photo d’exemple", legende: "Légende facultative sous la photo." %>
<%= render "charte/photo_attente", description: "Description de la photo attendue." %>
<%= render "charte/calendrier", jours: "vvovvvg vvvvovv ovvrvvv vvgvvvo ovvvvvv rrovrrr rorrvvo gvggggg gggvggg gogggvg ggoggvg vvgvvvv ovvvovv vrvvvov vvvvovv ovvgvvv vvovvrv vvvovvv vgvvvvo vvvvovv" %>
<%= form_with url: "#", class: "formulaire", data: { turbo: false } do |f| %>
<%= render "charte/champ", form: f, attribut: :email, libelle: "Votre adresse mail", precision: "(obligatoire)" do %>
<%= f.email_field :email, autocomplete: "email" %>
<% end %>
<%= render "charte/champ", form: f, attribut: :mot_de_passe, libelle: "Mot de passe" do %>
<%= f.password_field :mot_de_passe %>
<% end %>
<%= render "charte/champ", form: f, attribut: :message, libelle: "Un mot, une idée, une question ?", precision: "(facultatif)" do %>
<%= f.text_area :message, rows: 5 %>
<% end %>
<%= render "charte/formulaire_erreur", messages: ["Message d’erreur du formulaire."] %>
<%= f.submit "Envoyer", class: "bouton" %>
<% end %>
<%= render "charte/note", texte: "Une note discrète en fin de page." %>
</article>

View file

@ -0,0 +1,20 @@
= form_with model: day_log do |f|
- @day_log.errors.full_messages.each do |msg|
%p= msg
.field
= f.label :day
= f.date_field :day
.field
= f.label :info
= f.text_area :info
.field
= label_tag :mode_id, "Mode"
= select_tag "day_log[mode_id]",
options_from_collection_for_select(Current.user.modes, :id, :label, @mood&.mode_id),
include_blank: true
= f.submit

View file

@ -1,44 +0,0 @@
<% titre = @day_log.day ? date_longue(@day_log.day).capitalize : "Journal" %>
<% content_for :title, "#{titre} – Kluk" %>
<% mode_id = params.dig(:day_log, :mode_id).presence&.to_i || @mood&.mode_id %>
<% mode = Current.user.modes.find { |m| m.id == mode_id } %>
<div class="page-jour" data-controller="choix-mode">
<header class="page-jour-entete">
<p class="retour"><%= link_to "Retour à mes humeurs", dashboard_path(day: @day_log.day) %></p>
<h1 class="page-jour-titre"><%= titre %></h1>
</header>
<figure class="carte-humeur" data-choix-mode-target="carte" style="<%= "--humeur: #{mode.color}" if mode %>">
<%= image_tag (mode || Mode.new).image_url, alt: "", data: { choix_mode_target: "image" } %>
</figure>
<%= form_with model: @day_log, class: "formulaire" do |f| %>
<%= render "charte/formulaire_erreur", messages: @day_log.errors.full_messages %>
<%= f.hidden_field :day %>
<fieldset class="champ">
<legend>Mode du jour</legend>
<ul class="choix-modes">
<% Current.user.modes.each do |m| %>
<li>
<%= radio_button_tag "day_log[mode_id]", m.id, m.id == mode_id,
data: { image: m.image_url, couleur: m.color, action: "choix-mode#changer" } %>
<%= label_tag "day_log_mode_id_#{m.id}" do %>
<span class="pastille" style="--humeur: <%= m.color %>"></span><%= m.label %>
<% end %>
</li>
<% end %>
</ul>
</fieldset>
<%= render "charte/champ", form: f, attribut: :info, libelle: "Journal" do %>
<%= f.text_area :info, rows: 8, placeholder: "Ce qui a compté ce jour-là…" %>
<% end %>
<div class="actions">
<%= f.submit "Enregistrer", class: "bouton" %>
<%= link_to "Annuler", dashboard_path(day: @day_log.day) %>
</div>
<% end %>
</div>

View file

@ -0,0 +1,3 @@
%h1 Modifier le journal
= render 'form', day_log: @day_log

View file

@ -1 +0,0 @@
<%= render template: "day_logs/edit" %>

View file

@ -0,0 +1,3 @@
%h1 Nouveau journal
= render 'form', day_log: @day_log

View file

@ -1,2 +1 @@
%article.texte %h1 Comment ça KLUK ?
%h1 Comment ça KLUK ?

View file

@ -1,16 +1,24 @@
<% content_for :title, "Bienvenue – Kluk" %> <div>
<article class="texte texte-etroit"> <h1>Set up your password</h1>
<%= render "charte/tasses" %> <p>Please set your password to activate your account.</p>
<h1>Bienvenue</h1>
<p>Choisissez un mot de passe pour activer votre compte et retrouver les humeurs de votre Kluk.</p>
<%= form_with model: @user, url: invitation_path(token: params[:token]), method: :patch, class: "formulaire" do |form| %> <%= form_with model: @user, url: invitation_path(token: params[:token]), method: :patch do |form| %>
<%= render "charte/champ", form: form, attribut: :password, libelle: "Mot de passe" do %> <div>
<%= form.password_field :password, required: true, autocomplete: "new-password", maxlength: 72 %> <%= form.password_field :password,
required: true,
autocomplete: "new-password",
placeholder: "Enter new password" %>
</div>
<div>
<%= form.password_field :password_confirmation,
required: true,
autocomplete: "new-password",
placeholder: "Repeat new password" %>
</div>
<div>
<%= form.submit "Activate Account" %>
</div>
<% end %> <% end %>
<%= render "charte/champ", form: form, attribut: :password_confirmation, libelle: "Le même, une seconde fois" do %> </div>
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", maxlength: 72 %>
<% end %>
<%= form.submit "Activer mon compte", class: "bouton" %>
<% end %>
</article>

View file

@ -1,26 +1,11 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fr"> <html>
<head> <head>
<title><%= content_for(:title) || "Kluk" %></title> <title><%= content_for(:title) || "Moodie" %></title>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#F5F6F0">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<% titre_partage = content_for(:title) || "Comment ça Kluk ?" %>
<% description = content_for(:description) || "Pas besoin de demander comment ça va : il suffit de regarder la tasse." %>
<meta name="description" content="<%= description %>">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Kluk">
<meta property="og:locale" content="fr_FR">
<meta property="og:title" content="<%= titre_partage %>">
<meta property="og:description" content="<%= description %>">
<meta property="og:url" content="<%= request.base_url + request.path %>">
<meta property="og:image" content="<%= image_url "charte/partage.png" %>">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Trois tasses de couleur alignées sur une table, et une quatrième posée sur la Kluk, fumante.">
<meta name="twitter:card" content="summary_large_image">
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= csp_meta_tag %> <%= csp_meta_tag %>
@ -29,11 +14,15 @@
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
<%= favicon_link_tag "charte/favicon.svg", type: "image/svg+xml" %> <link rel="icon" href="/icon.png" type="image/png">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icon.png"> <link rel="apple-touch-icon" href="/icon.png">
<%= preload_link_tag "young-serif-400.woff2" %> <link rel="preconnect" href="https://fonts.googleapis.com">
<%= preload_link_tag "atkinson-hyperlegible-next-400.woff2" %> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Delius&family=Sour+Gummy:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
<script src="https://kit.fontawesome.com/079b06296f.js" crossorigin="anonymous"></script>
<%# Includes all stylesheet files in app/assets/stylesheets %> <%# Includes all stylesheet files in app/assets/stylesheets %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
@ -41,12 +30,7 @@
</head> </head>
<body> <body>
<a class="evitement" href="#contenu">Aller au contenu</a> <%= render 'menu' %>
<%= render "entete" %>
<main id="contenu" tabindex="-1">
<%= render "flash" %>
<%= yield %> <%= yield %>
</main>
<%= render "pied" %>
</body> </body>
</html> </html>

View file

@ -1,30 +0,0 @@
<%# Calendrier des humeurs « à la GitHub » : une colonne par semaine, une ligne par jour,
regroupé par mois. Par défaut, un bloc par année, la plus récente d'abord ;
avec par_annee: false, tous les mois à la suite (l'année est ajoutée aux mois d'une autre année). %>
<%# locals: (history:, user:, interactive: false, journal: false, selected_date: nil, par_annee: true) -%>
<% blocs = par_annee ? history_by_year(history) : [ [ nil, history ] ] %>
<div class="calendrier-humeurs">
<% blocs.each do |year, months| %>
<%= tag.section class: "annee", "aria-labelledby": ("annee-#{year}" if year) do %>
<% if year %>
<h2 class="annee-titre" id="annee-<%= year %>"><%= year %></h2>
<% end %>
<div class="annee-mois">
<div class="semaine-jours" aria-hidden="true">
<span>lun.</span><span></span><span>mer.</span><span></span><span>ven.</span><span></span><span></span>
</div>
<% months.each do |month| %>
<div class="mois">
<h3 class="mois-nom"><%= l(month[:month], format: year || month[:month].year == Date.current.year ? "%B" : "%B %Y") %></h3>
<div class="mois-jours">
<% month[:weeks].flatten.each do |mood| %>
<%= case_jour(mood, user, interactive: interactive, journal: journal,
selected: mood[:recorded_at].to_date == selected_date) %>
<% end %>
</div>
</div>
<% end %>
</div>
<% end %>
<% end %>
</div>

View file

@ -1,18 +0,0 @@
<%# locals: (user:, journal: false) -%>
<div class="legende">
<ul class="legende-modes" aria-label="Les modes">
<% user.modes.each do |mode| %>
<li><span class="pastille" style="--humeur: <%= mode.color %>"></span><%= mode.label %></li>
<% end %>
</ul>
<ul class="legende-etats" aria-label="Les autres cases">
<% if user.guess? %>
<li><span class="pastille pastille-supposee"></span>Mode supposé, pas de tasse posée ce jour-là</li>
<% else %>
<li><span class="pastille pastille-inconnue"></span>Pas de tasse posée</li>
<% end %>
<% if journal %>
<li><span class="pastille pastille-journal"></span>Journal écrit</li>
<% end %>
</ul>
</div>

View file

@ -1,31 +1,52 @@
<% content_for :title, "Mes humeurs – Kluk" %> <main data-controller="mood" class="columns m-auto">
<% history = @user.history %> <div class="current-day column is-hidden-mobile">
<% selected_date = (Date.parse(params[:day]) rescue Date.current) %> <section class="m-4">
<% selected = find_day(history, selected_date) || find_day(history, selected_date = Date.current) %> <figure class="image has-ratio">
<% status = day_status(selected, @user) if selected %> <%= image_tag(@user.current_mode.image, "data-mood-target": "image") %>
<% mode = mode_for(selected, @user) if selected %>
<div class="tableau" data-controller="calendrier">
<div class="tableau-calendrier">
<h1 class="tableau-titre">Mes humeurs</h1>
<%= render "moods/legende", user: @user, journal: true %>
<%= render "moods/calendrier", history: history, user: @user, interactive: true, journal: true, selected_date: selected_date %>
</div>
<aside class="detail" aria-live="polite" aria-label="Jour sélectionné">
<figure class="carte-humeur" data-calendrier-target="carte anime" style="<%= "--humeur: #{mode.color}" if mode %>">
<%= image_tag (mode || Mode.new).image_url, alt: "", data: { calendrier_target: "image" } %>
</figure> </figure>
<div class="detail-texte" data-calendrier-target="anime"> </section>
<h2 class="detail-date" data-calendrier-target="date"><%= date_longue(selected_date) %></h2>
<p class="detail-mode">
<span data-calendrier-target="mode"><%= mode&.label || "Pas de tasse posée" %></span>
<span class="detail-statut" data-calendrier-target="statut"><%= libelle_statut(status) if mode %></span>
</p>
<% info = selected&.dig(:day_log)&.info %>
<p class="detail-journal <%= "detail-journal-vide" unless info %>" data-calendrier-target="journal" data-vide="Rien d’écrit ce jour-là."><%= info || "Rien d’écrit ce jour-là." %></p>
<%= link_to info ? "Modifier le journal" : "Écrire dans le journal", edit_day_log_for_day_path(day: selected_date),
class: "bouton bouton-petit", data: { calendrier_target: "lien" } %>
</div> </div>
</aside> <section class="section column has-background-primary-light is-flex is-flex-direction-column is-justify-content-space-around">
</div> <div class="tracker">
<div class="legend is-flex is-flex-wrap-wrap mb-5">
<div class="is-flex is-align-items-center mr-4">
<div class=""><strong>Légende</strong></div>
</div>
<% @user.modes.each do |mode| %>
<div class="is-flex is-align-items-center mr-4">
<div class="bar mr-1" style="<%= style_for_mode(mode) %>"></div>
<div class=""><%= mode.label %></div>
</div>
<% end %>
</div>
<div class="container mb-4 p-3 has-background-white day-info">
<div class="title is-4">
<span class="icon"><i class="fa-regular fa-calendar"></i></span>
<span data-mood-target="modeDay">Aujourd'hui : <%= @user.current_mode.label %></span>
<%= link_to "Journal", edit_day_log_for_day_path(day: Date.today), "data-mood-target": "dayLogLink" %>
</div>
</div>
<div class="logs">
<div class="is-flex is-flex-direction-row is-flex-wrap-wrap mb-3">
<% @user.history.each do |month| %>
<div class="mr-3">
<div> <%= I18n.l(month[:month], format: "%b %y").capitalize %></div>
<div class="is-flex is-flex-wrap-wrap">
<% month[:weeks].each do |week| %>
<div class="is-flex is-flex-direction-column is-flex-wrap-wrap mb-3">
<% week.each do |mood| %>
<% mode = mode_for(mood, @user) %>
<div data-image="<%= mode[:image_url] %>" data-mode="<%= mode[:label] %>" data-day="<%= l mood[:recorded_at].to_date %>" data-action="click->mood#updateDayInfo" title="<%= l(mood[:recorded_at].to_date) %> : <%= mode[:label] %>" class="day" style="<%= style_for_mode(mode) %>"></div>
<% end %>
</div>
<% end %>
</div>
</div>
<% end %>
<div id="end"></>
</div>
</div>
</div>
</section>
</main>

View file

@ -1,51 +0,0 @@
<% content_for :title, "Comment ça Kluk chez #{@user.username} ?" %>
<% content_for :description, "Les humeurs #{de_nom(@user.username)}, jour après jour, au rythme des tasses posées sur la Kluk." %>
<% history = MoodCalendarService.generate_calendar(@user, start_date: 5.months.ago.beginning_of_month, end_date: Date.current) %>
<% selected_date = (Date.parse(params[:day]) rescue Date.current) %>
<% selected = find_day(history, selected_date) if selected_date < Date.current %>
<% selected_date = Date.current unless selected %>
<% aujourdhui = selected_date == Date.current %>
<% mode_du_jour = @user.moods.last&.mode %>
<% mode = aujourdhui ? mode_du_jour : mode_for(selected, @user) %>
<% status = day_status(selected, @user) unless aujourdhui %>
<% since = @user.current_mode_since %>
<article class="public" data-controller="calendrier"
data-calendrier-aujourdhui-value="<%= Date.current.iso8601 %>"
data-calendrier-aujourdhui-date-value="Aujourd’hui, <%= l(Date.current, format: "%A %-d %B") %>"
data-calendrier-aujourdhui-mode-value="<%= mode_du_jour&.label || "Pas de tasse posée" %>"
data-calendrier-aujourdhui-image-value="<%= (mode_du_jour || Mode.new).image_url %>"
data-calendrier-aujourdhui-couleur-value="<%= mode_du_jour&.color %>">
<h1 class="public-nom">Les humeurs <%= de_nom(@user.username) %></h1>
<figure class="carte-humeur carte-humeur-grande" data-calendrier-target="carte anime" style="<%= "--humeur: #{mode.color}" if mode %>">
<%= image_tag (mode || Mode.new).image_url, alt: "", data: { calendrier_target: "image" } %>
</figure>
<div class="public-texte">
<div class="public-jour" data-calendrier-target="anime" aria-live="polite">
<p class="public-date" data-calendrier-target="date"><%= aujourdhui ? "Aujourd’hui, #{l(Date.current, format: "%A %-d %B")}" : date_longue(selected_date) %></p>
<p class="public-mode" data-calendrier-target="mode"><%= mode&.label || "Pas de tasse posée" %></p>
<p class="public-depuis">
<span data-calendrier-target="depuis" <%= "hidden" unless aujourdhui %>>
<% if since == Date.current %>
Depuis ce matin.
<% elsif since %>
Depuis le <%= l(since, format: since.year == Date.current.year ? "%A %-d %B" : "%-d %B %Y") %>.
<% end %>
</span>
<span class="public-autre-jour" data-calendrier-target="retour" <%= "hidden" if aujourdhui %>>
<span class="detail-statut" data-calendrier-target="statut"><%= libelle_statut(status) if mode %></span>
<button type="button" class="lien" data-action="calendrier#revenir">Revenir à aujourd’hui</button>
</span>
</p>
</div>
<section class="public-historique" aria-labelledby="public-historique-titre">
<h2 id="public-historique-titre">Les derniers mois</h2>
<%= render "moods/legende", user: @user %>
<%= render "moods/calendrier", history: history, user: @user, par_annee: false,
interactive: true, selected_date: selected_date %>
</section>
</div>
</article>

View file

@ -1,14 +1,9 @@
<% content_for :title, "Nouveau mot de passe – Kluk" %> <h1>Update your password</h1>
<article class="texte texte-etroit">
<h1>Nouveau mot de passe</h1>
<%= form_with url: password_path(params[:token]), method: :put, class: "formulaire" do |form| %> <%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
<%= render "charte/champ", form: form, attribut: :password, libelle: "Nouveau mot de passe" do %>
<%= form.password_field :password, required: true, autocomplete: "new-password", maxlength: 72 %> <%= form_with url: password_path(params[:token]), method: :put do |form| %>
<% end %> <%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72 %><br>
<%= render "charte/champ", form: form, attribut: :password_confirmation, libelle: "Le même, une seconde fois" do %> <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72 %><br>
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", maxlength: 72 %> <%= form.submit "Save" %>
<% end %> <% end %>
<%= form.submit "Enregistrer le mot de passe", class: "bouton" %>
<% end %>
</article>

View file

@ -1,13 +1,8 @@
<% content_for :title, "Mot de passe oublié – Kluk" %> <h1>Forgot your password?</h1>
<article class="texte texte-etroit">
<h1>Mot de passe oublié</h1>
<p>Indiquez l’adresse mail de votre compte : vous recevrez un lien pour choisir un nouveau mot de passe.</p>
<%= form_with url: passwords_path, class: "formulaire" do |form| %> <%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
<%= render "charte/champ", form: form, attribut: :email_address, libelle: "Adresse mail" do %>
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", value: params[:email_address] %> <%= form_with url: passwords_path do |form| %>
<% end %> <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %><br>
<%= form.submit "Recevoir le lien", class: "bouton" %> <%= form.submit "Email reset instructions" %>
<p class="formulaire-secours"><%= link_to "Retour à la connexion", new_session_path %></p> <% end %>
<% end %>
</article>

View file

@ -1,16 +0,0 @@
<% content_for :title, "Connexion – Kluk" %>
<article class="texte texte-etroit">
<%= render "charte/tasses" %>
<h1>Connexion</h1>
<%= form_with url: session_path, class: "formulaire" do |form| %>
<%= render "charte/champ", form: form, attribut: :email_address, libelle: "Adresse mail" do %>
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", value: params[:email_address] %>
<% end %>
<%= render "charte/champ", form: form, attribut: :password, libelle: "Mot de passe" do %>
<%= form.password_field :password, required: true, autocomplete: "current-password", maxlength: 72 %>
<% end %>
<%= form.submit "Se connecter", class: "bouton" %>
<p class="formulaire-secours"><%= link_to "Mot de passe oublié ?", new_password_path %></p>
<% end %>
</article>

View file

@ -0,0 +1,22 @@
%main.columns.m-auto{"data-controller" => "mood"}
.column.is-hidden-mobile
%section.m-4
%figure.image.has-ratio
= image_tag("croisiere.jpg")
%section.section.column.has-background-primary-light.is-flex.is-flex-direction-column.is-justify-content-center
%h1.title Connexion au tableau de bord de la KLUK
.flash.mb-2
.has-text-danger= flash[:alert] if flash[:alert]
.has-text-info= flash[:notice] if flash[:notice]
= form_with url: session_path do |form|
.field
= form.label :email_address, "Email"
.control
= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Entrez votre email", value: params[:email_address], class: "input"
.field
= form.label :password, "Mot de passe"
.control
= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Entrez votre mot de passe", maxlength: 72, class: "input"
.field
.control
%button.button.is-link Se connecter

View file

@ -1,389 +0,0 @@
# Charte graphique Kluk
Charte extraite du site kluk.fr (`assets/css/style.scss`, `_layouts/`, `_includes/`).
Tout ce qu'il faut pour la réutiliser ailleurs se trouve dans le dossier [`charte/`](charte/) :
```
charte/
├── css/kluk.css Feuille de style complète, en CSS pur (sans Sass)
├── fonts/ Young Serif, Atkinson Hyperlegible Next (woff2) + licence OFL
├── img/
│ ├── favicon.svg Icône du site (tasse verte)
│ ├── illustration-tasses.svg Illustration « table et tasses », autonome (styles intégrés)
│ ├── tasse-{gris,orange,vert,rouge}.svg Une tasse seule, dans chaque couleur d'humeur
│ └── photo-attente.svg Pictogramme de l'emplacement de photo
└── exemple.html Page de démonstration de tous les composants
```
Pour démarrer : copier `charte/` dans le projet, puis dans le `<head>` :
```html
<meta name="theme-color" content="#F5F6F0">
<link rel="preload" href="charte/fonts/young-serif-400.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="charte/fonts/atkinson-hyperlegible-next-400.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="charte/css/kluk.css">
<link rel="icon" href="charte/img/favicon.svg" type="image/svg+xml">
```
Les chemins des polices dans `kluk.css` sont relatifs (`../fonts/`) : garder la structure `css/` + `fonts/` côte à côte.
---
## 1. Esprit
Un univers de petit-déjeuner, calme et chaleureux : **la porcelaine du matin** (fond), **l'encre du thé** (texte), **le chêne de la table** (pied de page, illustration), et **les quatre couleurs des tasses** (gris, orange, vert, rouge) comme accents, qui sont aussi les quatre « humeurs ».
Principes :
- une seule colonne de lecture étroite (36 rem), beaucoup d'air ;
- des titres en serif ronde et amicale, un texte courant très lisible ;
- des formes arrondies (boutons en pilule, cartes arrondies), des traits d'encre épais façon dessin ;
- l'accessibilité d'abord : contrastes élevés, focus très visible, lien d'évitement, animations coupées si l'utilisateur le demande.
---
## 2. Couleurs
Toutes les couleurs sont des variables CSS déclarées sur `:root`.
### Fonds et texte
| Variable | Valeur | Rôle |
|---|---|---|
| `--porcelaine` | `#F5F6F0` | Fond de page, texte sur fond encre, `theme-color` |
| `--porcelaine-fonce` | `#E9ECE2` | Fond de `code`, emplacement de photo, liseré des cartes |
| `--encre` | `#2E2A26` | Texte, bordures, fond des boutons |
| `--encre-douce` | `#5A534C` | Texte secondaire : chapeau, notes, légendes, précisions |
| `--chene` | `#D9BC94` | Pied de page, plateau de la table (illustration) |
| `--chene-fonce` | `#B48D5F` | Socle de la Kluk, pointillés et picto de l'emplacement photo |
### Humeurs (accents)
| Variable | Valeur | Rôle |
|---|---|---|
| `--gris` | `#8D97A8` | Humeur grise |
| `--orange` | `#EDA33D` | Humeur orange, trait de l'exergue |
| `--vert` | `#5F9B59` | Humeur verte, soulignement des liens, focus des champs, favicon |
| `--rouge` | `#D8573F` | Humeur rouge, trait des messages d'erreur |
| `--vert-lien` | `#3F7A3B` | Vert foncé pour les cases à cocher (`accent-color`) |
### Teintes utilitaires
Écrites en dur dans le site d'origine ; `kluk.css` les expose en variables, avec les mêmes valeurs.
| Variable | Valeur | Rôle |
|---|---|---|
| `--encre-survol` | `#45403A` | Fond des boutons au survol |
| `--surface` | `#FBFCF8` | Fond des cartes (formulaire, calendrier) |
| `--bordure-champ` | `#8A8279` | Bordure des champs de saisie |
| `--erreur-fond` | `#FBE9E5` | Fond des messages d'erreur |
| `--blanc` | `#fff` | Fond des champs, reflet des tasses, fond à l'impression |
### Contrastes (WCAG)
| Couple | Ratio | Usage autorisé |
|---|---|---|
| encre / porcelaine | 13,1 : 1 | tout texte |
| encre-douce / porcelaine | 7,0 : 1 | tout texte |
| encre / chêne | 7,8 : 1 | tout texte (pied de page) |
| porcelaine / encre-survol | 9,4 : 1 | texte des boutons au survol |
| vert-lien / porcelaine | 4,8 : 1 | texte possible |
| vert / porcelaine | 3,1 : 1 | éléments graphiques seulement (soulignés, contours) |
| rouge / porcelaine | 3,6 : 1 | éléments graphiques seulement |
| gris / porcelaine | 2,7 : 1 | décoratif seulement |
| orange / porcelaine | 2,0 : 1 | décoratif seulement |
Règle : **le texte est toujours en encre ou encre douce**. Les couleurs d'humeur ne servent qu'aux traits, soulignés, pastilles et illustrations, jamais au texte.
---
## 3. Typographies
| Variable | Pile | Usage |
|---|---|---|
| `--serif` | `"Young Serif", Georgia, serif` | Titres `h1`–`h3`, exergue, phrase du pied de page |
| `--sans` | `"Atkinson Hyperlegible Next", "Atkinson Hyperlegible", system-ui, sans-serif` | Tout le reste, y compris le logo |
Fichiers fournis (`charte/fonts/`, licence SIL OFL 1.1 dans `OFL.txt`) :
- `young-serif-400.woff2` : Young Serif, une seule graisse (400) ;
- `atkinson-hyperlegible-next-400.woff2` : Atkinson Hyperlegible Next **variable**, graisses 200 à 800 ;
- `atkinson-hyperlegible-next-400-italic.woff2` : italique 400.
Graisses utilisées : 400 (texte), 700 (boutons, libellés, `strong`), 800 (logo). Les titres restent en 400 : c'est la serif qui porte le caractère.
### Échelle
| Élément | Taille | Interlignage | Remarques |
|---|---|---|---|
| Corps (`body`) | `1.1875rem` (19 px) | 1.65 | `font-kerning: normal` |
| `h1` | `clamp(2.35rem, 1.6rem + 3.6vw, 3.75rem)` | 1.15 | serif, `text-wrap: balance` |
| `h2` | `clamp(1.6rem, 1.35rem + 1vw, 2rem)` | 1.15 | serif |
| Chapeau | `1.3rem` | 1.55 | italique, encre douce |
| Exergue | `1.45rem` | 1.4 | serif |
| Logo | `1.35rem` | 1.2 | sans 800, `letter-spacing: 0.01em` |
| Phrase du pied | `1.15rem` | — | serif |
| Note | `1.0625rem` | — | encre douce |
| Navigation, petit bouton, pied, formulaire (erreur, secours), emplacement photo | `1rem` | — | |
| Légende de photo | `0.95rem` | — | encre douce |
| `code` | `0.9em` | — | |
Titres : `letter-spacing: -0.005em`.
### Typographie française
Le site applique automatiquement (plugin Jekyll `_plugins/typographie.rb`) une espace fine insécable avant `; ! ?` et une espace insécable avant `:` et `»` et après `«`. Dans un autre projet, taper ces espaces directement (`&#8239;` fine, `&nbsp;` insécable) ou reprendre ce plugin. Apostrophe typographique `’` et guillemets `« »`.
---
## 4. Espacements et mise en page
| Variable | Valeur | Rôle |
|---|---|---|
| `--colonne` | `36rem` | Largeur de la colonne de texte (~ 65 caractères) |
| `--gouttiere` | `1.25rem` | Marge latérale sur mobile, padding horizontal des conteneurs |
| `--largeur-entete` | `64rem` | Largeur maximale de l'en-tête |
| `--arrondi-petit` | `0.5rem` | Logo, champs de saisie |
| `--arrondi` | `0.75rem` | Photos, cartes (formulaire, calendrier, emplacement photo) |
| `--arrondi-pilule` | `999px` | Boutons |
Rythme vertical (marges) :
| Élément | Marge |
|---|---|
| Paragraphe | `0 0 1.15rem` |
| `h1` | `1.5rem 0` |
| `h2` | `4.5rem 0 1.25rem` (grande respiration avant chaque section) |
| Paragraphe contenant seulement un bouton | `2rem 0` |
| Photo | `2rem 0` |
| Exergue, calendrier | `2.25rem 0` (calendrier : `2.25rem 0 1rem`) |
| Formulaire | `2.5rem 0 1rem` |
| Note | `margin-top: 2.5rem` |
| Colonne `.texte` | padding `2rem` en haut, `5rem` en bas |
Points de rupture : `30rem` (calendrier resserré), `40rem` (formulaire plus aéré), `48rem` (l'illustration déborde de 4 rem de chaque côté de la colonne).
Structure de page : `body` en flex colonne avec `min-height: 100vh`, `main` en `flex: 1`, pour que le pied de page reste en bas.
Traits : encre, 2 à 3 px (boutons actifs 2 px, logo 2,5 px, pied de page 3 px, illustration 3 px, anses 6 px). Trait latéral de 4 px pour l'exergue (orange) et les erreurs (rouge).
---
## 5. Composants
La page [`charte/exemple.html`](charte/exemple.html) les montre tous.
### Squelette de page
```html
<body>
<a class="evitement" href="#contenu">Aller au contenu</a>
<header class="entete">…</header>
<main id="contenu" tabindex="-1">
<article class="texte">
…contenu…
</article>
</main>
<footer class="pied">…</footer>
</body>
```
`.evitement` est caché au-dessus de l'écran et n'apparaît qu'au focus clavier.
### En-tête, logo et navigation
```html
<header class="entete">
<a class="logo" href="/" aria-label="Kluk, retour à l'accueil">kluk</a>
<nav class="nav" aria-label="Navigation principale">
<ul>
<li><a href="/" aria-current="page">Accueil</a></li>
<li><a href="/histoire/">Comment est née l'idée</a></li>
<li><a href="/je-veux-ma-kluk/" class="bouton bouton-petit">Je veux ma Kluk</a></li>
</ul>
</nav>
</header>
```
- Le logo est un mot-symbole en minuscules, en Atkinson Hyperlegible Next graisse 800, encadré d'un trait d'encre arrondi.
- Les liens de navigation ne sont pas soulignés ; le soulignement vert apparaît au survol et sur la page courante (`aria-current="page"`).
- Une seule entrée de navigation est mise en valeur en bouton. Quand c'est la page courante, le bouton s'inverse (fond porcelaine, contour encre).
### Liens
Texte en encre, soulignement vert épais (`0.12em`, décalé de `0.2em`) qui passe à l'encre au survol. Dans le pied de page, soulignement encre fin (1 px, 2 px au survol).
### Focus
Contour encre de 3 px, décalé de 3 px, coins arrondis à 4 px, sur tous les éléments (`:focus-visible`). Les champs de formulaire utilisent un contour vert.
### Bouton
```html
<p><a class="bouton" href="/je-veux-ma-kluk/">Je veux ma Kluk</a></p>
<a class="bouton bouton-petit" href="…">Petit bouton</a>
<button class="bouton" type="submit">Envoyer</button>
```
Pilule encre, texte porcelaine en 700. Un bouton seul dans son paragraphe prend une marge de 2 rem. Dans un `.formulaire`, un `<button class="bouton">` perd sa bordure native et passe à `opacity: 0.6` quand il est `disabled`.
### Titres, chapeau, texte
```html
<h1>Titre de la page</h1>
<p><em>Le chapeau : une phrase d'accroche en italique.</em></p>
<p>Texte courant…</p>
<h2>Intertitre</h2>
```
Le chapeau n'a pas de classe : c'est **le paragraphe qui suit directement le `h1` et ne contient qu'un `<em>`** (sélecteur `h1 + p:has(> em:only-child)`).
### Exergue
```html
<p class="exergue">Une phrase à mettre en valeur.</p>
```
### Note
```html
<p class="note">Une précision discrète, en fin de page.</p>
```
### Code en ligne
```html
<code>exemple</code>
```
### Photo
```html
<figure class="photo">
<img src="photo.jpg" alt="Description de la photo" width="1200" height="900" loading="lazy" decoding="async">
<figcaption>Légende facultative.</figcaption>
</figure>
```
Toujours un `alt` descriptif, et `width`/`height` pour éviter les sauts de mise en page. Format des photos du site : 1200 × 900 (4:3) ; image de partage : 1200 × 630.
### Emplacement de photo (en attendant l'image)
```html
<figure class="photo">
<div class="photo-attente">
<svg viewBox="0 0 40 32" aria-hidden="true" focusable="false"><path d="M3 3 L6 25 Q6.5 29 10.5 29 L24.5 29 Q28.5 29 29 25 L32 3 Z M31.5 8 C39 8 39.5 21 30 22" /></svg>
<p><strong>Photo à venir</strong><br>Description de la photo attendue.</p>
</div>
</figure>
```
Cadre 3:2 en pointillés chêne foncé. Le pictogramme est aussi fourni seul : `img/photo-attente.svg`.
### Illustration « table et tasses »
Deux façons de l'utiliser :
1. **En image**, le plus simple (couleurs et animation intégrées au fichier) :
```html
<figure class="tasses">
<img src="charte/img/illustration-tasses.svg" alt="Trois tasses de couleur alignées sur la table, et une quatrième posée sur la Kluk, fumante.">
</figure>
```
2. **En SVG dans la page**, pour qu'elle suive les variables CSS : copier le bloc `<figure class="tasses">` de `charte/exemple.html`. La couleur de chaque tasse vient de sa classe d'humeur (`humeur-gris`, `humeur-orange`, `humeur-rouge`, `humeur-vert`) via `currentColor`. On peut donc réutiliser le symbole `#tasse` :
```html
<use href="#tasse" x="34" y="114" class="humeur-orange" />
```
La vapeur ondule doucement (5 s, deux volutes décalées de 2,5 s), seulement si `prefers-reduced-motion` ne le refuse pas. À partir de 48 rem, la figure déborde de 4 rem de part et d'autre de la colonne.
Tasses seules : `img/tasse-gris.svg`, `tasse-orange.svg`, `tasse-vert.svg`, `tasse-rouge.svg`.
### Calendrier d'humeurs (décoratif)
```html
<div class="calendrier" aria-hidden="true">
<span class="jour jour-v"></span>
<span class="jour jour-o"></span>
<span class="jour jour-g"></span>
<span class="jour jour-r"></span>
<!-- … une case par jour ; la grille remplit des colonnes de 7 jours (une semaine par colonne) -->
</div>
```
`jour-g` gris, `jour-o` orange, `jour-v` vert, `jour-r` rouge. Carte de fond `--surface` avec liseré intérieur `--porcelaine-fonce`.
### Formulaire
```html
<form class="formulaire">
<div class="champ">
<label for="champ-email">Votre adresse mail <span class="precision">(obligatoire)</span></label>
<input id="champ-email" name="Email" type="email" required autocomplete="email">
</div>
<fieldset class="champ">
<legend>Ce que vous aimeriez faire <span class="precision">(cochez tout ce qui vous ressemble)</span></legend>
<ul class="choix">
<li>
<input id="choix-1" type="checkbox" name="choix[]" value="1">
<label for="choix-1">Premier choix</label>
</li>
</ul>
</fieldset>
<div class="champ">
<label for="champ-message">Un mot, une idée, une question ? <span class="precision">(facultatif)</span></label>
<textarea id="champ-message" name="Message" rows="5"></textarea>
</div>
<!-- Champ piège anti-robots : invisible, laissé vide par les humains -->
<div class="piege" aria-hidden="true">
<label for="champ-site">Ne pas remplir</label>
<input id="champ-site" name="_site" type="text" tabindex="-1" autocomplete="off">
</div>
<p class="formulaire-erreur" role="alert" hidden></p>
<button class="bouton" type="submit">Envoyer</button>
<noscript><p class="formulaire-secours">Ce formulaire a besoin de JavaScript.</p></noscript>
</form>
```
- Libellés en 700 au-dessus des champs, précisions en 400 encre douce entre parenthèses.
- Champs : fond blanc, bordure 2 px `--bordure-champ`, arrondi 0,5 rem ; au focus, contour vert 3 px et bordure encre.
- Les styles ne visent que `input[type="email"]` et `textarea` : ajouter les autres types au sélecteur si besoin.
- Cases à cocher agrandies (1,35 rem), teintées `--vert-lien`.
- Message d'erreur : trait rouge à gauche sur fond rosé.
### Pied de page
```html
<footer class="pied">
<div class="pied-interieur">
<p class="pied-phrase">Une petite phrase facultative.</p>
<ul class="pied-liens">
<li><a href="/je-veux-ma-kluk/">Je veux ma Kluk</a></li>
<li><a href="/mentions-legales/">Mentions légales</a></li>
</ul>
</div>
</footer>
```
C'est « la table en bois » : fond chêne, trait d'encre de 3 px en haut, comme le plateau de l'illustration.
---
## 6. Impression
La navigation, le pied de page, l'illustration et le calendrier sont masqués ; le fond devient blanc.
## 7. Icône
`img/favicon.svg` : une tasse verte (`--vert`) cernée d'encre. `theme-color` : `#F5F6F0`.

View file

@ -1,528 +0,0 @@
/*
* Kluk — charte graphique, feuille de style réutilisable.
* Extraite de assets/css/style.scss (site kluk.fr), en CSS pur, sans Sass.
*
* Palette : la porcelaine du matin, l'encre du thé, le chêne de la table,
* et les quatre couleurs des tasses en accent.
*
* Sommaire
* 1. Polices
* 2. Variables (couleurs, typographies, mise en page)
* 3. Base
* 4. Liens, focus, lien d'évitement
* 5. Boutons
* 6. En-tête et navigation
* 7. Colonne de texte et titres
* 8. Illustration « tasses »
* 9. Calendrier d'humeurs
* 10. Photos
* 11. Formulaire
* 12. Pied de page
* 13. Impression
*/
/* ---------- 1. Polices ---------- */
@font-face {
font-family: "Young Serif";
src: url("../fonts/young-serif-400.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* Fichier variable : couvre toutes les graisses de 200 à 800 */
@font-face {
font-family: "Atkinson Hyperlegible Next";
src: url("../fonts/atkinson-hyperlegible-next-400.woff2") format("woff2");
font-weight: 200 800;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Atkinson Hyperlegible Next";
src: url("../fonts/atkinson-hyperlegible-next-400-italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
font-display: swap;
}
/* ---------- 2. Variables ---------- */
:root {
/* Fonds et texte */
--porcelaine: #F5F6F0;
--porcelaine-fonce: #E9ECE2;
--encre: #2E2A26;
--encre-douce: #5A534C;
--chene: #D9BC94;
--chene-fonce: #B48D5F;
/* Humeurs (les quatre tasses) */
--gris: #8D97A8;
--orange: #EDA33D;
--vert: #5F9B59;
--rouge: #D8573F;
--vert-lien: #3F7A3B;
/* Teintes utilitaires (écrites en dur dans le site d'origine) */
--encre-survol: #45403A;
--surface: #FBFCF8;
--bordure-champ: #8A8279;
--erreur-fond: #FBE9E5;
--blanc: #fff;
/* Typographies */
--serif: "Young Serif", Georgia, serif;
--sans: "Atkinson Hyperlegible Next", "Atkinson Hyperlegible", system-ui, sans-serif;
/* Mise en page */
--colonne: 36rem;
--gouttiere: 1.25rem;
--largeur-entete: 64rem;
/* Arrondis */
--arrondi-petit: 0.5rem;
--arrondi: 0.75rem;
--arrondi-pilule: 999px;
}
/* ---------- 3. Base ---------- */
*,
*::before,
*::after { box-sizing: border-box; }
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--porcelaine);
color: var(--encre);
font-family: var(--sans);
font-size: 1.1875rem;
line-height: 1.65;
font-kerning: normal;
hyphens: manual;
}
main { flex: 1; outline: none; }
img, svg { display: block; max-width: 100%; }
/* ---------- 4. Liens, focus, lien d'évitement ---------- */
a {
color: var(--encre);
text-decoration-line: underline;
text-decoration-color: var(--vert);
text-decoration-thickness: 0.12em;
text-underline-offset: 0.2em;
}
a:hover { text-decoration-color: var(--encre); }
:focus-visible {
outline: 3px solid var(--encre);
outline-offset: 3px;
border-radius: 4px;
}
.evitement {
position: absolute;
left: var(--gouttiere);
top: -4rem;
padding: 0.5rem 1rem;
background: var(--encre);
color: var(--porcelaine);
z-index: 10;
}
.evitement:focus { top: 0.75rem; }
/* ---------- 5. Boutons ---------- */
.bouton {
display: inline-block;
padding: 0.8rem 1.6rem;
border-radius: var(--arrondi-pilule);
background: var(--encre);
color: var(--porcelaine);
font-weight: 700;
line-height: 1.3;
text-decoration: none;
transition: background-color 0.15s ease;
}
.bouton:hover { background: var(--encre-survol); color: var(--porcelaine); }
.bouton:focus-visible { outline-offset: 4px; border-radius: var(--arrondi-pilule); }
.bouton-petit {
padding: 0.45rem 1rem;
font-size: 1rem;
}
/* Un bouton seul dans son paragraphe respire davantage */
p:has(> .bouton:only-child) { margin: 2rem 0; }
/* ---------- 6. En-tête et navigation ---------- */
.entete {
max-width: var(--largeur-entete);
margin: 0 auto;
padding: 1.25rem var(--gouttiere);
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem 2rem;
width: 100%;
}
.logo {
display: inline-block;
padding: 0.05rem 0.55rem 0.15rem;
border: 2.5px solid var(--encre);
border-radius: var(--arrondi-petit);
font-family: var(--sans);
font-weight: 800;
font-size: 1.35rem;
line-height: 1.2;
letter-spacing: 0.01em;
text-decoration: none;
}
.nav ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 1.35rem;
font-size: 1rem;
}
.nav a:not(.bouton) {
text-decoration-color: transparent;
}
.nav a:not(.bouton):hover,
.nav a[aria-current="page"]:not(.bouton) {
text-decoration-color: var(--vert);
}
.nav a.bouton[aria-current="page"] {
background: var(--porcelaine);
color: var(--encre);
box-shadow: inset 0 0 0 2px var(--encre);
}
/* ---------- 7. Colonne de texte et titres ---------- */
.texte {
max-width: calc(var(--colonne) + 2 * var(--gouttiere));
margin: 0 auto;
padding: 2rem var(--gouttiere) 5rem;
}
h1, h2, h3 {
font-family: var(--serif);
font-weight: 400;
line-height: 1.15;
letter-spacing: -0.005em;
text-wrap: balance;
}
h1 {
font-size: clamp(2.35rem, 1.6rem + 3.6vw, 3.75rem);
margin: 1.5rem 0 1.5rem;
}
h2 {
font-size: clamp(1.6rem, 1.35rem + 1vw, 2rem);
margin: 4.5rem 0 1.25rem;
}
p { margin: 0 0 1.15rem; }
/* Le chapeau : paragraphe entièrement en italique juste sous le titre */
h1 + p:has(> em:only-child) {
font-size: 1.3rem;
line-height: 1.55;
color: var(--encre-douce);
}
.note {
margin-top: 2.5rem;
color: var(--encre-douce);
font-size: 1.0625rem;
}
.exergue {
margin: 2.25rem 0;
padding-left: 1.25rem;
border-left: 4px solid var(--orange);
font-family: var(--serif);
font-size: 1.45rem;
line-height: 1.4;
}
code {
font-size: 0.9em;
background: var(--porcelaine-fonce);
padding: 0.05em 0.3em;
border-radius: 3px;
}
/* ---------- 8. Illustration « tasses » ---------- */
.tasses {
margin: 1.5rem 0 0;
}
/* L'illustration déborde légèrement de la colonne sur grand écran */
@media (min-width: 48rem) {
.tasses { margin-inline: -4rem; }
}
.tasses svg { width: 100%; height: auto; overflow: visible; }
.table { fill: var(--chene); }
.table-fil,
.tasse-corps,
.tasse-anse,
.kluk-socle,
.vapeur path {
stroke: var(--encre);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.tasse-anse { fill: none; stroke-width: 6; }
.tasse-corps { fill: currentColor; }
.tasse-reflet { fill: none; stroke: var(--blanc); stroke-opacity: 0.45; stroke-width: 4; stroke-linecap: round; }
.kluk-socle { fill: var(--chene-fonce); }
.kluk-voyant { fill: var(--porcelaine); }
.vapeur path { fill: none; stroke-width: 2.5; stroke: var(--encre-douce); }
.humeur-gris { color: var(--gris); }
.humeur-orange { color: var(--orange); }
.humeur-rouge { color: var(--rouge); }
.humeur-vert { color: var(--vert); }
@media (prefers-reduced-motion: no-preference) {
.vapeur path {
animation: vapeur 5s ease-in-out infinite;
}
.vapeur path + path { animation-delay: 2.5s; }
}
@keyframes vapeur {
0%, 100% { opacity: 0.25; transform: translateY(6px); }
50% { opacity: 0.9; transform: translateY(-6px); }
}
/* ---------- 9. Calendrier d'humeurs (décoratif) ---------- */
.calendrier {
display: grid;
grid-template-rows: repeat(7, auto);
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: 4px;
margin: 2.25rem 0 1rem;
padding: 1.25rem;
background: var(--surface);
border-radius: var(--arrondi);
box-shadow: inset 0 0 0 1px var(--porcelaine-fonce);
}
.jour {
aspect-ratio: 1;
border-radius: 2px;
}
.jour-g { background: var(--gris); }
.jour-o { background: var(--orange); }
.jour-v { background: var(--vert); }
.jour-r { background: var(--rouge); }
@media (max-width: 30rem) {
.calendrier { gap: 3px; padding: 0.9rem; }
}
/* ---------- 10. Photos ---------- */
.photo {
margin: 2rem 0;
}
.photo img {
width: 100%;
height: auto;
border-radius: var(--arrondi);
}
.photo figcaption {
margin-top: 0.6rem;
font-size: 0.95rem;
color: var(--encre-douce);
}
.photo-attente {
display: flex;
align-items: center;
gap: 1.25rem;
aspect-ratio: 3 / 2;
padding: 1.5rem;
border: 2px dashed var(--chene-fonce);
border-radius: var(--arrondi);
background: var(--porcelaine-fonce);
color: var(--encre-douce);
font-size: 1rem;
line-height: 1.5;
}
.photo-attente svg {
flex: none;
width: 2.75rem;
fill: none;
stroke: var(--chene-fonce);
stroke-width: 2.5;
stroke-linejoin: round;
stroke-linecap: round;
}
.photo-attente p { margin: 0; }
.photo-attente strong { color: var(--encre); }
/* ---------- 11. Formulaire ---------- */
.formulaire {
margin: 2.5rem 0 1rem;
padding: 1.75rem 1.25rem;
background: var(--surface);
border-radius: var(--arrondi);
box-shadow: inset 0 0 0 1px var(--porcelaine-fonce);
}
@media (min-width: 40rem) {
.formulaire { padding: 2.25rem 2.5rem; }
}
.champ {
margin: 0 0 1.75rem;
padding: 0;
border: 0;
min-width: 0;
}
.champ > label,
.champ > legend {
display: block;
padding: 0;
margin-bottom: 0.5rem;
font-weight: 700;
}
.precision {
font-weight: 400;
color: var(--encre-douce);
}
.formulaire input[type="email"],
.formulaire textarea {
display: block;
width: 100%;
padding: 0.7rem 0.85rem;
border: 2px solid var(--bordure-champ);
border-radius: var(--arrondi-petit);
background: var(--blanc);
color: var(--encre);
font: inherit;
line-height: 1.4;
}
.formulaire textarea { resize: vertical; min-height: 8rem; }
.formulaire input[type="email"]:focus-visible,
.formulaire textarea:focus-visible {
outline: 3px solid var(--vert);
outline-offset: 1px;
border-color: var(--encre);
}
.choix {
list-style: none;
margin: 0;
padding: 0;
}
.choix li {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.35rem 0;
}
.choix input {
flex: none;
width: 1.35rem;
height: 1.35rem;
margin: 0.2rem 0 0;
accent-color: var(--vert-lien);
cursor: pointer;
}
.choix label { cursor: pointer; }
/* Champ piège anti-robots, invisible */
.piege {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.formulaire-erreur {
padding: 0.75rem 1rem;
border-left: 4px solid var(--rouge);
background: var(--erreur-fond);
font-size: 1rem;
}
.formulaire .bouton {
border: 0;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.formulaire .bouton:disabled { opacity: 0.6; cursor: progress; }
.formulaire-secours {
margin: 1rem 0 0;
font-size: 1rem;
color: var(--encre-douce);
}
/* ---------- 12. Pied de page : la table en bois ---------- */
.pied {
background: var(--chene);
border-top: 3px solid var(--encre);
font-size: 1rem;
}
.pied-interieur {
max-width: calc(var(--colonne) + 2 * var(--gouttiere));
margin: 0 auto;
padding: 2rem var(--gouttiere) 2.5rem;
}
.pied-phrase {
font-family: var(--serif);
font-size: 1.15rem;
margin: 0 0 1rem;
}
.pied-liens {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem 2rem;
}
.pied a { text-decoration-color: var(--encre); text-decoration-thickness: 1px; }
.pied a:hover { text-decoration-thickness: 2px; }
/* ---------- 13. Impression ---------- */
@media print {
.entete .nav, .pied, .tasses, .calendrier { display: none; }
body { background: var(--blanc); }
}

View file

@ -1,122 +0,0 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#F5F6F0">
<title>Charte Kluk — exemple</title>
<link rel="preload" href="fonts/young-serif-400.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="fonts/atkinson-hyperlegible-next-400.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="css/kluk.css">
<link rel="icon" href="img/favicon.svg" type="image/svg+xml">
</head>
<body>
<a class="evitement" href="#contenu">Aller au contenu</a>
<header class="entete">
<a class="logo" href="#" aria-label="Kluk, retour à l'accueil">kluk</a>
<nav class="nav" aria-label="Navigation principale">
<ul>
<li><a href="#" aria-current="page">Accueil</a></li>
<li><a href="#">Une autre page</a></li>
<li><a href="#" class="bouton bouton-petit">Appel à l’action</a></li>
</ul>
</nav>
</header>
<main id="contenu" tabindex="-1">
<article class="texte">
<h1>Titre de page en Young&nbsp;Serif</h1>
<p><em>Le chapeau : un paragraphe entièrement en italique, juste sous le titre.</em></p>
<p>Texte courant en Atkinson Hyperlegible Next, avec <a href="#">un lien souligné de vert</a>, du <strong>gras</strong>, de l’<em>italique</em> et du <code>code</code>.</p>
<p><a class="bouton" href="#">Bouton principal</a></p>
<figure class="tasses">
<svg viewBox="0 0 580 214" role="img" aria-labelledby="tasses-titre">
<title id="tasses-titre">Illustration : trois tasses de couleur alignées sur la table, et une quatrième posée sur la Kluk, fumante.</title>
<defs>
<g id="tasse">
<path class="tasse-anse" d="M63 22 C86 20 88 60 60 62" />
<path class="tasse-corps" d="M3 6 L10 78 Q11 86 20 86 L48 86 Q57 86 58 78 L65 6 Z" />
<path class="tasse-reflet" d="M13 18 L17 62" />
</g>
</defs>
<rect class="table" x="0" y="200" width="580" height="14" rx="3" />
<path class="table-fil" d="M0 200 H580" />
<use href="#tasse" x="34" y="114" class="humeur-gris" />
<use href="#tasse" x="132" y="114" class="humeur-orange" />
<use href="#tasse" x="230" y="114" class="humeur-rouge" />
<g class="kluk-illu" transform="translate(410 98)">
<g class="vapeur" aria-hidden="true">
<path d="M22 -6 c-9 -11 9 -20 0 -31 c-9 -11 9 -20 0 -31" />
<path d="M44 -10 c-9 -11 9 -20 0 -31 c-9 -11 9 -20 0 -31" />
</g>
<use href="#tasse" x="0" y="0" class="humeur-vert" />
<rect class="kluk-socle" x="-22" y="86" width="112" height="16" rx="8" />
<circle class="kluk-voyant" cx="34" cy="94" r="2.6" />
</g>
</svg>
</figure>
<h2>Intertitre de section</h2>
<p>Les intertitres ménagent un grand espace au-dessus d’eux (4,5&nbsp;rem) pour découper la page en respirations.</p>
<p class="exergue">Une phrase mise en exergue, en serif, soulignée d’un trait orange.</p>
<figure class="photo">
<div class="photo-attente">
<svg viewBox="0 0 40 32" aria-hidden="true" focusable="false"><path d="M3 3 L6 25 Q6.5 29 10.5 29 L24.5 29 Q28.5 29 29 25 L32 3 Z M31.5 8 C39 8 39.5 21 30 22" /></svg>
<p><strong>Photo à venir</strong><br>Description de la photo attendue.</p>
</div>
<figcaption>Légende facultative sous la photo.</figcaption>
</figure>
<div class="calendrier" aria-hidden="true" id="calendrier"></div>
<form class="formulaire" onsubmit="return false">
<div class="champ">
<label for="champ-email">Votre adresse mail <span class="precision">(obligatoire)</span></label>
<input id="champ-email" name="Email" type="email" required autocomplete="email">
</div>
<fieldset class="champ">
<legend>Ce que vous aimeriez faire <span class="precision">(cochez tout ce qui vous ressemble)</span></legend>
<ul class="choix">
<li><input id="choix-1" type="checkbox" name="choix[]" value="1" checked><label for="choix-1">Premier choix</label></li>
<li><input id="choix-2" type="checkbox" name="choix[]" value="2"><label for="choix-2">Deuxième choix</label></li>
</ul>
</fieldset>
<div class="champ">
<label for="champ-message">Un mot, une idée, une question ? <span class="precision">(facultatif)</span></label>
<textarea id="champ-message" name="Message" rows="5"></textarea>
</div>
<p class="formulaire-erreur" role="alert">Message d’erreur (masqué par défaut avec l’attribut <code>hidden</code>).</p>
<button class="bouton" type="submit">Envoyer</button>
<p class="formulaire-secours">Texte de secours, affiché dans un <code>&lt;noscript&gt;</code>.</p>
</form>
<p class="note">Une note discrète en fin de page.</p>
</article>
</main>
<footer class="pied">
<div class="pied-interieur">
<p class="pied-phrase">Une petite phrase en serif, facultative.</p>
<ul class="pied-liens">
<li><a href="#">Premier lien</a></li>
<li><a href="#">Mentions légales</a></li>
</ul>
</div>
</footer>
<script>
// Remplit le calendrier de démonstration : chaque groupe de 7 lettres est une semaine.
// g = gris, o = orange, v = vert, r = rouge.
var semaines = "vvovvvg vvvvovv ovvrvvv vvgvvvo ovvvvvv rrovrrr rorrvvo gvggggg gggvggg gogggvg ggoggvg vvgvvvv ovvvovv vrvvvov vvvvovv ovvgvvv vvovvrv vvvovvv vgvvvvo vvvvovv";
document.getElementById("calendrier").innerHTML = semaines.replace(/ /g, "").split("").map(function (j) {
return '<span class="jour jour-' + j + '"></span>';
}).join("");
</script>
</body>
</html>

View file

@ -1,97 +0,0 @@
Young Serif
Copyright 2023 The Young Serif Project Authors (https://github.com/noirblancrouge/YoungSerif)
Atkinson Hyperlegible Next
Copyright 2020-2024 The Atkinson Hyperlegible Next Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M4 4 L7 24 Q7.5 28 11.5 28 L19.5 28 Q23.5 28 24 24 L27 4 Z" fill="#5F9B59" stroke="#2E2A26" stroke-width="2.5" stroke-linejoin="round"/><path d="M26.5 9 C32 9 32 20 25 21" fill="none" stroke="#2E2A26" stroke-width="3" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 318 B

View file

@ -1,48 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 214" role="img" aria-labelledby="t">
<title id="t">Trois tasses de couleur alignées sur la table, et une quatrième posée sur la Kluk, fumante.</title>
<style>
.table { fill: #D9BC94; }
.table-fil, .tasse-corps, .tasse-anse, .kluk-socle, .vapeur path {
stroke: #2E2A26; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round;
}
.tasse-anse { fill: none; stroke-width: 6; }
.tasse-corps { fill: currentColor; }
.tasse-reflet { fill: none; stroke: #fff; stroke-opacity: 0.45; stroke-width: 4; stroke-linecap: round; }
.kluk-socle { fill: #B48D5F; }
.kluk-voyant { fill: #F5F6F0; }
.vapeur path { fill: none; stroke-width: 2.5; stroke: #5A534C; }
.humeur-gris { color: #8D97A8; }
.humeur-orange { color: #EDA33D; }
.humeur-rouge { color: #D8573F; }
.humeur-vert { color: #5F9B59; }
@media (prefers-reduced-motion: no-preference) {
.vapeur path { animation: vapeur 5s ease-in-out infinite; }
.vapeur path + path { animation-delay: 2.5s; }
}
@keyframes vapeur {
0%, 100% { opacity: 0.25; transform: translateY(6px); }
50% { opacity: 0.9; transform: translateY(-6px); }
}
</style>
<defs>
<g id="tasse">
<path class="tasse-anse" d="M63 22 C86 20 88 60 60 62" />
<path class="tasse-corps" d="M3 6 L10 78 Q11 86 20 86 L48 86 Q57 86 58 78 L65 6 Z" />
<path class="tasse-reflet" d="M13 18 L17 62" />
</g>
</defs>
<rect class="table" x="0" y="200" width="580" height="14" rx="3" />
<path class="table-fil" d="M0 200 H580" />
<use href="#tasse" x="34" y="114" class="humeur-gris" />
<use href="#tasse" x="132" y="114" class="humeur-orange" />
<use href="#tasse" x="230" y="114" class="humeur-rouge" />
<g transform="translate(410 98)">
<g class="vapeur">
<path d="M22 -6 c-9 -11 9 -20 0 -31 c-9 -11 9 -20 0 -31" />
<path d="M44 -10 c-9 -11 9 -20 0 -31 c-9 -11 9 -20 0 -31" />
</g>
<use href="#tasse" x="0" y="0" class="humeur-vert" />
<rect class="kluk-socle" x="-22" y="86" width="112" height="16" rx="8" />
<circle class="kluk-voyant" cx="34" cy="94" r="2.6" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 44 36"><path d="M3 3 L6 25 Q6.5 29 10.5 29 L24.5 29 Q28.5 29 29 25 L32 3 Z M31.5 8 C39 8 39.5 21 30 22" fill="none" stroke="#B48D5F" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 262 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-3 -3 95 95"><path d="M63 22 C86 20 88 60 60 62" fill="none" stroke="#2E2A26" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 6 L10 78 Q11 86 20 86 L48 86 Q57 86 58 78 L65 6 Z" fill="#8D97A8" stroke="#2E2A26" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 18 L17 62" fill="none" stroke="#fff" stroke-opacity="0.45" stroke-width="4" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 472 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-3 -3 95 95"><path d="M63 22 C86 20 88 60 60 62" fill="none" stroke="#2E2A26" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 6 L10 78 Q11 86 20 86 L48 86 Q57 86 58 78 L65 6 Z" fill="#EDA33D" stroke="#2E2A26" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 18 L17 62" fill="none" stroke="#fff" stroke-opacity="0.45" stroke-width="4" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 472 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-3 -3 95 95"><path d="M63 22 C86 20 88 60 60 62" fill="none" stroke="#2E2A26" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 6 L10 78 Q11 86 20 86 L48 86 Q57 86 58 78 L65 6 Z" fill="#D8573F" stroke="#2E2A26" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 18 L17 62" fill="none" stroke="#fff" stroke-opacity="0.45" stroke-width="4" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 472 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-3 -3 95 95"><path d="M63 22 C86 20 88 60 60 62" fill="none" stroke="#2E2A26" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 6 L10 78 Q11 86 20 86 L48 86 Q57 86 58 78 L65 6 Z" fill="#5F9B59" stroke="#2E2A26" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 18 L17 62" fill="none" stroke="#fff" stroke-opacity="0.45" stroke-width="4" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 472 B

View file

@ -13,7 +13,6 @@ Rails.application.routes.draw do
# get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
mount LetterOpenerWeb::Engine, at: "/letter_opener" if Rails.env.development? mount LetterOpenerWeb::Engine, at: "/letter_opener" if Rails.env.development?
get "/charte", to: "charte#show" if Rails.env.development?
# Defines the root path route ("/") # Defines the root path route ("/")
root "home#index" root "home#index"

View file

@ -1,15 +1,13 @@
class ReplaceMoodModeStringWithModeReference < ActiveRecord::Migration[8.0] class ReplaceMoodModeStringWithModeReference < ActiveRecord::Migration[8.0]
def up def up
rename_column :moods, :mode, :mode_string
# 1. Ajouter la colonne de référence # 1. Ajouter la colonne de référence
add_reference :moods, :mode, foreign_key: true add_reference :moods, :mode, foreign_key: true
# 2. Pour chaque mood, trouver ou créer le Mode correspondant # 2. Pour chaque mood, trouver ou créer le Mode correspondant
Mood.find_each do |mood| Mood.find_each do |mood|
next if mood.mode_string.blank? next if mood.mode.blank?
mode_record = Mode.find_or_create_by!(label: mood.mode_string, user_id: mood.user_id) do |m| mode_record = Mode.find_or_create_by!(label: mood.mode, user_id: mood.user_id) do |m|
m.color = "#000000" # couleur par défaut m.color = "#000000" # couleur par défaut
end end
@ -17,18 +15,16 @@ class ReplaceMoodModeStringWithModeReference < ActiveRecord::Migration[8.0]
end end
# 3. Supprimer l'ancienne colonne string # 3. Supprimer l'ancienne colonne string
remove_column :moods, :mode_string, :string remove_column :moods, :mode, :string
end end
def down def down
add_column :moods, :mode_string, :string add_column :moods, :mode, :string
Mood.find_each do |mood| Mood.find_each do |mood|
mood.update_column(:mode_string, mood.mode&.label) mood.update_column(:mode, mood.mode&.label)
end end
remove_reference :moods, :mode, foreign_key: true remove_reference :moods, :mode, foreign_key: true
rename_column :moods, :mode_string, :mode
end end
end end

View file

@ -1,6 +0,0 @@
class UpdateRfidTagsModeFromStringToMode < ActiveRecord::Migration[8.0]
def change
remove_column :rfid_tags, :mode
add_reference :rfid_tags, :mode
end
end

5
db/schema.rb generated
View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_07_29_144217) do ActiveRecord::Schema[8.0].define(version: 2026_03_19_181607) do
create_table "active_storage_attachments", force: :cascade do |t| create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@ -70,13 +70,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_29_144217) do
create_table "rfid_tags", force: :cascade do |t| create_table "rfid_tags", force: :cascade do |t|
t.string "identifier" t.string "identifier"
t.string "mode"
t.datetime "created_at", default: -> { "CURRENT_DATE" }, null: false t.datetime "created_at", default: -> { "CURRENT_DATE" }, null: false
t.datetime "updated_at", default: -> { "CURRENT_DATE" }, null: false t.datetime "updated_at", default: -> { "CURRENT_DATE" }, null: false
t.string "chip_id" t.string "chip_id"
t.integer "user_id" t.integer "user_id"
t.integer "mode_id"
t.index ["identifier"], name: "index_rfid_tags_on_identifier", unique: true t.index ["identifier"], name: "index_rfid_tags_on_identifier", unique: true
t.index ["mode_id"], name: "index_rfid_tags_on_mode_id"
t.index ["user_id"], name: "index_rfid_tags_on_user_id" t.index ["user_id"], name: "index_rfid_tags_on_user_id"
end end

7
package-lock.json generated
View file

@ -6,6 +6,7 @@
"": { "": {
"name": "app", "name": "app",
"dependencies": { "dependencies": {
"bulma": "^1.0.4",
"sass": "^1.94.2", "sass": "^1.94.2",
"yarn": "^1.22.22" "yarn": "^1.22.22"
} }
@ -319,6 +320,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/bulma": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/bulma/-/bulma-1.0.4.tgz",
"integrity": "sha512-Ffb6YGXDiZYX3cqvSbHWqQ8+LkX6tVoTcZuVB3lm93sbAVXlO0D6QlOTMnV6g18gILpAXqkG2z9hf9z4hCjz2g==",
"license": "MIT"
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",

View file

@ -2,10 +2,11 @@
"name": "app", "name": "app",
"private": "true", "private": "true",
"dependencies": { "dependencies": {
"bulma": "^1.0.4",
"sass": "^1.94.2", "sass": "^1.94.2",
"yarn": "^1.22.22" "yarn": "^1.22.22"
}, },
"scripts": { "scripts": {
"build:css": "sass ./app/assets/stylesheets/application.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules" "build:css": "sass ./app/assets/stylesheets/application.bulma.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules"
} }
} }

View file

@ -1,21 +1,5 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe User, type: :model do RSpec.describe User, type: :model do
describe "#current_mode_since" do pending "add some examples to (or delete) #{__FILE__}"
let(:user) { create(:user) }
let(:croisiere) { create(:mode, user: user) }
let(:creatif) { create(:mode, user: user) }
it "returns nil without moods" do
expect(user.current_mode_since).to be_nil
end
it "returns the first day of the latest run of the current mode" do
create(:mood, user: user, mode: croisiere, recorded_at: DateTime.parse("2026-01-10 08:00"))
create(:mood, user: user, mode: creatif, recorded_at: DateTime.parse("2026-01-12 08:00"))
create(:mood, user: user, mode: creatif, recorded_at: DateTime.parse("2026-01-14 08:00"))
expect(user.current_mode_since).to eq(Date.parse("2026-01-12"))
end
end
end end

View file

@ -1,23 +0,0 @@
require 'rails_helper'
describe "Home", type: :request do
describe "GET / on a public user's subdomain" do
let(:user) { create(:user, username: "alice", public: true) }
before { host! "#{user.username}.example.com" }
it "exposes OpenGraph tags for sharing" do
get root_path, params: { day: "2026-01-15" }
expect(response).to have_http_status(:ok)
page = Nokogiri::HTML(response.body)
meta = ->(attr, name) { page.at_css("meta[#{attr}='#{name}']")&.[]("content") }
expect(meta.("property", "og:title")).to eq("Comment ça Kluk chez alice\u202F?")
expect(meta.("property", "og:description")).to start_with("Les humeurs d’alice")
expect(meta.("property", "og:url")).to eq("http://alice.example.com/")
expect(meta.("property", "og:image")).to match(%r{\Ahttp://alice\.example\.com/assets/charte/partage-\h+\.png\z})
expect(meta.("name", "twitter:card")).to eq("summary_large_image")
end
end
end

View file

@ -1,231 +1,330 @@
# spec/services/mood_calendar_service_spec.rb
require 'rails_helper' require 'rails_helper'
RSpec.describe MoodCalendarService do RSpec.describe MoodCalendarService do
let(:user) { create(:user) }
let(:mode) { create(:mode, user: user) }
describe '.generate_calendar' do describe '.generate_calendar' do
context 'avec des jours manquants' do context 'avec un recorded_at pour chaque jour' do
it 'remplit les jours manquants avec mode: nil et guess égal au dernier mode' do it 'génère un calendrier complet sans jours nil' do
mode2 = create(:mode, user: user) # Données du 1er au 7 février 2025 (une semaine complète)
mode3 = create(:mode, user: user) (1..7).each do |day|
create(:mood, user: user, mode: mode2, recorded_at: Time.zone.parse("2025-02-12 14:20:00")) create(:mood, mode: "mood_#{day}", recorded_at: Time.zone.parse("2025-02-0#{day} 10:00:00"))
create(:mood, user: user, mode: mode3, recorded_at: Time.zone.parse("2025-02-15 10:00:00")) end
create(:mood, user: user, mode: mode, recorded_at: Time.zone.parse("2025-02-18 16:45:00"))
result = MoodCalendarService.generate_calendar( result = MoodCalendarService.generate_calendar(
user, Mood.all,
end_date: Date.parse("2025-02-28") end_date: Date.parse("2025-02-28")
) )
expect(result.length).to be >= 1 expect(result.length).to eq(1) # Un seul mois
expect(result.any? { |m| m[:month] == Date.parse("2025-02-01") }).to be true expect(result[0][:month]).to eq(Date.parse("2025-02-01"))
all_days = result.find { |m| m[:month] == Date.parse("2025-02-01") }[:weeks].flatten # Première semaine commence le lundi 3 février
first_week = result[0][:weeks][0]
expect(first_week.length).to eq(7) # Du lundi 3 au dimanche 9
# Vérifier que tous les jours du 3 au 7 ont un mode
(3..7).each do |day|
mood = first_week.find { |m| m[:recorded_at].day == day }
expect(mood[:mode]).to eq("mood_#{day}")
expect(mood).not_to have_key(:guess)
end
# Le 8 et 9 février devraient avoir mode: nil avec guess: "mood_7"
mood_8 = first_week.find { |m| m[:recorded_at].day == 8 }
expect(mood_8[:mode]).to be_nil
expect(mood_8[:guess]).to eq("mood_7")
mood_9 = first_week.find { |m| m[:recorded_at].day == 9 }
expect(mood_9[:mode]).to be_nil
expect(mood_9[:guess]).to eq("mood_7")
end
end
context 'avec des jours manquants' do
it 'remplit les jours manquants avec mode: nil et guess égal au dernier mode' do
create(:mood, mode: "triste", recorded_at: Time.zone.parse("2025-02-12 08:30:00"))
create(:mood, mode: "content", recorded_at: Time.zone.parse("2025-02-12 14:20:00"))
create(:mood, mode: "heureux", recorded_at: Time.zone.parse("2025-02-15 10:00:00"))
create(:mood, mode: "calme", recorded_at: Time.zone.parse("2025-02-18 16:45:00"))
moods = Mood.all
result = MoodCalendarService.generate_calendar(
moods,
end_date: Date.parse("2025-02-28")
)
expect(result.length).to eq(1)
expect(result[0][:month]).to eq(Date.parse("2025-02-01"))
# Trouver tous les jours
all_days = result[0][:weeks].flatten
# Le 12 février devrait avoir le dernier mood (content à 14:20)
mood_12 = all_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-02-12") } mood_12 = all_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-02-12") }
expect(mood_12[:mode]).to eq(mode2) expect(mood_12[:mode]).to eq("content")
expect(mood_12[:recorded_at].to_i).to eq(Time.zone.parse("2025-02-12 14:20:00").to_i) expect(mood_12[:recorded_at].to_i).to eq(Time.zone.parse("2025-02-12 14:20:00").to_i)
# Le 13 février devrait être nil avec guess: "content"
mood_13 = all_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-02-13") } mood_13 = all_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-02-13") }
expect(mood_13[:mode]).to be_nil expect(mood_13[:mode]).to be_nil
expect(mood_13[:guess]).to eq(mode2) expect(mood_13[:guess]).to eq("content")
# Le 14 février devrait être nil avec guess: "content"
mood_14 = all_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-02-14") } mood_14 = all_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-02-14") }
expect(mood_14[:mode]).to be_nil expect(mood_14[:mode]).to be_nil
expect(mood_14[:guess]).to eq(mode2) expect(mood_14[:guess]).to eq("content")
# Le 15 février devrait avoir le mood "heureux"
mood_15 = all_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-02-15") } mood_15 = all_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-02-15") }
expect(mood_15[:mode]).to eq(mode3) expect(mood_15[:mode]).to eq("heureux")
# Le 16 février devrait être nil avec guess: "heureux"
mood_16 = all_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-02-16") } mood_16 = all_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-02-16") }
expect(mood_16[:mode]).to be_nil expect(mood_16[:mode]).to be_nil
expect(mood_16[:guess]).to eq(mode3) expect(mood_16[:guess]).to eq("heureux")
# Les jours avant le 12 (à partir du premier lundi 3 février) devraient avoir guess: nil
mood_3 = all_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-02-03") } mood_3 = all_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-02-03") }
expect(mood_3[:mode]).to be_nil expect(mood_3[:mode]).to be_nil
expect(mood_3[:guess]).to be_nil expect(mood_3[:guess]).to be_nil
end end
it 'gère correctement plusieurs mois avec des jours manquants' do it 'gère correctement plusieurs mois avec des jours manquants' do
mode2 = create(:mode, user: user) create(:mood, mode: "heureux", recorded_at: Time.zone.parse("2025-02-15 10:00:00"))
create(:mood, user: user, mode: mode, recorded_at: Time.zone.parse("2025-02-15 10:00:00")) create(:mood, mode: "joyeux", recorded_at: Time.zone.parse("2025-04-20 11:30:00"))
create(:mood, user: user, mode: mode2, recorded_at: Time.zone.parse("2025-04-20 11:30:00"))
result = MoodCalendarService.generate_calendar( result = MoodCalendarService.generate_calendar(
user, Mood.all,
end_date: Date.parse("2025-04-30") end_date: Date.parse("2025-04-30")
) )
expect(result.length).to be >= 3 # Devrait avoir exactement février, mars et avril
expect(result.any? { |m| m[:month] == Date.parse("2025-02-01") }).to be true expect(result.length).to eq(3)
expect(result.any? { |m| m[:month] == Date.parse("2025-03-01") }).to be true
expect(result.any? { |m| m[:month] == Date.parse("2025-04-01") }).to be true
# Vérifier février
february = result.find { |m| m[:month] == Date.parse("2025-02-01") }
expect(february).not_to be_nil
# Vérifier mars (tous les jours devraient avoir mode: nil et guess: "heureux")
march = result.find { |m| m[:month] == Date.parse("2025-03-01") } march = result.find { |m| m[:month] == Date.parse("2025-03-01") }
expect(march).not_to be_nil
all_march_days = march[:weeks].flatten all_march_days = march[:weeks].flatten
# Tous les jours de mars (y compris ceux qui débordent début avril) devraient avoir mode: nil
expect(all_march_days.all? { |d| d[:mode].nil? }).to be true expect(all_march_days.all? { |d| d[:mode].nil? }).to be true
# Tous les jours de mars devraient avoir guess: "heureux"
# (le mois de mars va du 3 mars au 6 avril, avant le mood du 20 avril)
all_march_days.each do |day| all_march_days.each do |day|
expect(day[:guess]).to be_present, expect(day[:guess]).to eq("heureux"),
"Le jour #{day[:recorded_at].to_date} devrait avoir un guess" "Le jour #{day[:recorded_at].to_date} devrait avoir guess: 'heureux' mais a: #{day[:guess].inspect}"
end end
# Vérifier avril
april = result.find { |m| m[:month] == Date.parse("2025-04-01") } april = result.find { |m| m[:month] == Date.parse("2025-04-01") }
expect(april).not_to be_nil
all_april_days = april[:weeks].flatten all_april_days = april[:weeks].flatten
mood_20_april = all_april_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-04-20") } mood_20_april = all_april_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-04-20") }
expect(mood_20_april[:mode]).to eq(mode2) expect(mood_20_april[:mode]).to eq("joyeux")
# Les jours du 7 au 19 avril devraient avoir guess: "heureux"
mood_10_april = all_april_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-04-10") } mood_10_april = all_april_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-04-10") }
expect(mood_10_april[:guess]).to eq(mode) expect(mood_10_april[:guess]).to eq("heureux")
# Les jours après le 20 avril devraient avoir guess: "joyeux"
mood_21_april = all_april_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-04-21") } mood_21_april = all_april_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-04-21") }
expect(mood_21_april[:guess]).to eq(mode2) expect(mood_21_april[:guess]).to eq("joyeux")
end end
end end
context 'sans aucun recorded_at' do context 'sans aucun recorded_at' do
it 'retourne un calendrier à partir de la date courante' do it 'retourne un tableau vide' do
result = MoodCalendarService.generate_calendar(user) result = MoodCalendarService.generate_calendar(Mood.none)
expect(result).not_to be_empty expect(result).to eq([])
expect(result.first[:month]).to eq(Date.current.beginning_of_month) end
end
context 'avec plusieurs moods le même jour' do
it 'garde uniquement le mood le plus récent' do
create(:mood, mode: "triste", recorded_at: Time.zone.parse("2025-02-12 08:30:00"))
create(:mood, mode: "neutre", recorded_at: Time.zone.parse("2025-02-12 12:00:00"))
create(:mood, mode: "content", recorded_at: Time.zone.parse("2025-02-12 14:20:00"))
create(:mood, mode: "fatigué", recorded_at: Time.zone.parse("2025-02-12 09:15:00"))
moods = Mood.all
result = MoodCalendarService.generate_calendar(
moods,
end_date: Date.parse("2025-02-28")
)
all_days = result[0][:weeks].flatten
mood_12 = all_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-02-12") }
expect(mood_12[:mode]).to eq("content")
expect(mood_12[:recorded_at].to_i).to eq(Time.zone.parse("2025-02-12 14:20:00").to_i)
end end
end end
context 'structure du calendrier' do context 'structure du calendrier' do
it 'commence chaque mois par le premier lundi' do it 'commence chaque mois par le premier lundi' do
create(:mood, user: user, mode: mode, recorded_at: Time.zone.parse("2025-02-15 10:00:00")) create(:mood, mode: "heureux", recorded_at: Time.zone.parse("2025-02-15 10:00:00"))
result = MoodCalendarService.generate_calendar( result = MoodCalendarService.generate_calendar(
user, Mood.all,
end_date: Date.parse("2025-02-28") end_date: Date.parse("2025-02-28")
) )
february = result[0] february = result[0]
first_day = february[:weeks][0][0] first_day = february[:weeks][0][0]
# Le premier jour de février 2025 devrait être le lundi 3 février
expect(first_day[:recorded_at].to_date).to eq(Date.parse("2025-02-03")) expect(first_day[:recorded_at].to_date).to eq(Date.parse("2025-02-03"))
expect(first_day[:recorded_at].to_date.monday?).to be true expect(first_day[:recorded_at].to_date.monday?).to be true
end end
it 'va du premier lundi du mois au dimanche avant le premier lundi du mois suivant' do it 'va du premier lundi du mois au dimanche avant le premier lundi du mois suivant' do
create(:mood, user: user, mode: mode, recorded_at: Time.zone.parse("2025-02-15 10:00:00")) create(:mood, mode: "heureux", recorded_at: Time.zone.parse("2025-02-15 10:00:00"))
result = MoodCalendarService.generate_calendar( result = MoodCalendarService.generate_calendar(
user, Mood.all,
end_date: Date.parse("2025-03-31") end_date: Date.parse("2025-03-31")
) )
february = result[0] february = result[0]
all_days = february[:weeks].flatten all_days = february[:weeks].flatten
# Premier jour : lundi 3 février 2025
first_day = all_days.first first_day = all_days.first
expect(first_day[:recorded_at].to_date).to eq(Date.parse("2025-02-03")) expect(first_day[:recorded_at].to_date).to eq(Date.parse("2025-02-03"))
expect(first_day[:recorded_at].to_date.monday?).to be true expect(first_day[:recorded_at].to_date.monday?).to be true
# Dernier jour : dimanche 2 mars 2025 (veille du premier lundi de mars qui est le 3)
last_day = all_days.last last_day = all_days.last
expect(last_day[:recorded_at].to_date).to eq(Date.parse("2025-03-02")) expect(last_day[:recorded_at].to_date).to eq(Date.parse("2025-03-02"))
expect(last_day[:recorded_at].to_date.sunday?).to be true expect(last_day[:recorded_at].to_date.sunday?).to be true
# Le 3 mars (premier lundi de mars) ne devrait PAS être dans février
expect(all_days.any? { |d| d[:recorded_at].to_date == Date.parse("2025-03-03") }).to be false expect(all_days.any? { |d| d[:recorded_at].to_date == Date.parse("2025-03-03") }).to be false
end end
it 'toutes les semaines ont exactement 7 jours' do it 'toutes les semaines ont exactement 7 jours' do
create(:mood, user: user, mode: mode, recorded_at: Time.zone.parse("2025-02-15 10:00:00")) create(:mood, mode: "heureux", recorded_at: Time.zone.parse("2025-02-15 10:00:00"))
result = MoodCalendarService.generate_calendar( result = MoodCalendarService.generate_calendar(
user, Mood.all,
end_date: Date.parse("2025-02-28") end_date: Date.parse("2025-02-28")
) )
february = result[0] february = result[0]
# Toutes les semaines devraient avoir exactement 7 jours
february[:weeks].each do |week| february[:weeks].each do |week|
expect(week.length).to eq(7) expect(week.length).to eq(7)
end end
end end
it 'retourne month comme Date (le premier du mois)' do it 'retourne month comme Date (le premier du mois)' do
create(:mood, user: user, mode: mode, recorded_at: Time.zone.parse("2025-02-15 10:00:00")) create(:mood, mode: "heureux", recorded_at: Time.zone.parse("2025-02-15 10:00:00"))
result = MoodCalendarService.generate_calendar( result = MoodCalendarService.generate_calendar(
user, Mood.all,
end_date: Date.parse("2025-02-28") end_date: Date.parse("2025-02-28")
) )
february = result[0] february = result[0]
# month devrait être une Date
expect(february[:month]).to be_a(Date) expect(february[:month]).to be_a(Date)
expect(february[:month]).to eq(Date.parse("2025-02-01")) expect(february[:month]).to eq(Date.parse("2025-02-01"))
expect(february[:month].day).to eq(1) expect(february[:month].day).to eq(1) # Premier du mois
end end
end end
context 'avec paramètres par défaut' do context 'avec paramètres par défaut' do
it "utilise le premier mood comme start_date et aujourd'hui comme end_date" do it 'utilise le premier mood comme start_date et aujourd\'hui comme end_date' do
create(:mood, user: user, mode: mode, recorded_at: Time.zone.parse("2025-02-15 10:00:00")) create(:mood, mode: "heureux", recorded_at: Time.zone.parse("2025-02-15 10:00:00"))
result = MoodCalendarService.generate_calendar(user) result = MoodCalendarService.generate_calendar(Mood.all)
expect(result).not_to be_empty expect(result).not_to be_empty
# Devrait commencer en février
expect(result.first[:month]).to eq(Date.parse("2025-02-01")) expect(result.first[:month]).to eq(Date.parse("2025-02-01"))
end end
end end
context 'avec start_date spécifié' do context 'avec start_date spécifié' do
it 'commence à la date spécifiée' do it 'commence à la date spécifiée' do
create(:mood, user: user, mode: mode, recorded_at: Time.zone.parse("2025-02-15 10:00:00")) create(:mood, mode: "heureux", recorded_at: Time.zone.parse("2025-02-15 10:00:00"))
result = MoodCalendarService.generate_calendar( result = MoodCalendarService.generate_calendar(
user, Mood.all,
start_date: Date.parse("2025-03-01"), start_date: Date.parse("2025-03-01"),
end_date: Date.parse("2025-03-31") end_date: Date.parse("2025-03-31")
) )
# Devrait commencer en mars même si le mood est en février
expect(result.first[:month]).to eq(Date.parse("2025-03-01")) expect(result.first[:month]).to eq(Date.parse("2025-03-01"))
# Ne devrait pas contenir février
expect(result.any? { |m| m[:month] == Date.parse("2025-02-01") }).to be false expect(result.any? { |m| m[:month] == Date.parse("2025-02-01") }).to be false
# Tous les jours de mars devraient avoir guess: "heureux" (du 15 février)
all_march_days = result.first[:weeks].flatten
puts all_march_days.inspect
expect(all_march_days.all? { |d| d[:guess] == "heureux" }).to be true
end end
end end
context 'avec end_date spécifié' do context 'avec end_date spécifié' do
it 'se termine à end_date' do it 'se termine à la date spécifiée' do
create(:mood, user: user, mode: mode, recorded_at: Time.zone.parse("2025-02-15 10:00:00")) create(:mood, mode: "heureux", recorded_at: Time.zone.parse("2025-02-15 10:00:00"))
result = MoodCalendarService.generate_calendar( result = MoodCalendarService.generate_calendar(
user, Mood.all,
end_date: Date.parse("2025-09-30") end_date: Date.parse("2025-02-28")
) )
expect(result.last[:month]).to eq(Date.parse("2025-09-01")) # Devrait s'arrêter en février
expect(result.last[:month]).to eq(Date.parse("2025-02-01"))
# Ne devrait pas contenir mars ou au-delà
expect(result.any? { |m| m[:month] == Date.parse("2025-03-01") }).to be false
end end
end end
context 'avec start_date et end_date spécifiés' do context 'avec start_date et end_date spécifiés' do
it 'génère le calendrier pour la plage spécifiée' do it 'génère le calendrier pour la plage spécifiée' do
mode2 = create(:mode, user: user) create(:mood, mode: "heureux", recorded_at: Time.zone.parse("2025-02-15 10:00:00"))
create(:mood, user: user, mode: mode, recorded_at: Time.zone.parse("2025-02-15 10:00:00")) create(:mood, mode: "calme", recorded_at: Time.zone.parse("2025-04-20 10:00:00"))
create(:mood, user: user, mode: mode2, recorded_at: Time.zone.parse("2025-04-20 10:00:00"))
result = MoodCalendarService.generate_calendar( result = MoodCalendarService.generate_calendar(
user, Mood.all,
start_date: Date.parse("2025-03-01"), start_date: Date.parse("2025-03-01"),
end_date: Date.parse("2025-03-31") end_date: Date.parse("2025-03-31")
) )
# Devrait contenir uniquement mars
expect(result.length).to eq(1) expect(result.length).to eq(1)
expect(result.first[:month]).to eq(Date.parse("2025-03-01")) expect(result.first[:month]).to eq(Date.parse("2025-03-01"))
# Tous les jours de mars devraient avoir guess: "heureux" (du 15 février)
all_days = result.first[:weeks].flatten all_days = result.first[:weeks].flatten
expect(all_days.all? { |d| d[:mode].nil? }).to be true expect(all_days.all? { |d| d[:mode].nil? }).to be true
expect(all_days.all? { |d| d[:guess] == "heureux" }).to be true
end end
it 'gère une plage de plusieurs mois' do it 'gère une plage de plusieurs mois' do
create(:mood, user: user, mode: mode, recorded_at: Time.zone.parse("2025-02-15 10:00:00")) create(:mood, mode: "heureux", recorded_at: Time.zone.parse("2025-02-15 10:00:00"))
result = MoodCalendarService.generate_calendar( result = MoodCalendarService.generate_calendar(
user, Mood.all,
start_date: Date.parse("2025-02-01"), start_date: Date.parse("2025-02-01"),
end_date: Date.parse("2025-04-30") end_date: Date.parse("2025-04-30")
) )
# Devrait contenir février, mars et avril
months = result.map { |m| m[:month] } months = result.map { |m| m[:month] }
expect(months).to include( expect(months).to include(
Date.parse("2025-02-01"), Date.parse("2025-02-01"),
@ -235,10 +334,11 @@ RSpec.describe MoodCalendarService do
end end
it 'fonctionne avec DateTime en paramètres' do it 'fonctionne avec DateTime en paramètres' do
create(:mood, user: user, mode: mode, recorded_at: Time.zone.parse("2025-02-15 10:00:00")) create(:mood, mode: "heureux", recorded_at: Time.zone.parse("2025-02-15 10:00:00"))
# Passer des DateTime au lieu de Date
result = MoodCalendarService.generate_calendar( result = MoodCalendarService.generate_calendar(
user, Mood.all,
start_date: Time.zone.parse("2025-02-01 00:00:00"), start_date: Time.zone.parse("2025-02-01 00:00:00"),
end_date: Time.zone.parse("2025-02-28 23:59:59") end_date: Time.zone.parse("2025-02-28 23:59:59")
) )
@ -250,109 +350,72 @@ RSpec.describe MoodCalendarService do
context 'avec une plage ne contenant aucun mood' do context 'avec une plage ne contenant aucun mood' do
it 'génère un calendrier avec tous les jours à nil et guess du dernier mood avant la plage' do it 'génère un calendrier avec tous les jours à nil et guess du dernier mood avant la plage' do
create(:mood, user: user, mode: mode, recorded_at: Time.zone.parse("2025-02-15 10:00:00")) create(:mood, mode: "heureux", recorded_at: Time.zone.parse("2025-02-15 10:00:00"))
result = MoodCalendarService.generate_calendar( result = MoodCalendarService.generate_calendar(
user, Mood.all,
start_date: Date.parse("2025-04-01"), start_date: Date.parse("2025-04-01"),
end_date: Date.parse("2025-04-30") end_date: Date.parse("2025-04-30")
) )
# Devrait contenir avril
expect(result.first[:month]).to eq(Date.parse("2025-04-01")) expect(result.first[:month]).to eq(Date.parse("2025-04-01"))
# Tous les jours devraient avoir mode: nil et guess: "heureux"
all_days = result.first[:weeks].flatten all_days = result.first[:weeks].flatten
expect(all_days.all? { |d| d[:mode].nil? }).to be true expect(all_days.all? { |d| d[:mode].nil? }).to be true
expect(all_days.all? { |d| d[:guess] == "heureux" }).to be true
end end
end end
context 'avec plusieurs mois' do context 'avec plusieurs mois' do
it 'chaque mois commence et finit correctement' do it 'chaque mois commence et finit correctement' do
mode2 = create(:mode, user: user) create(:mood, mode: "heureux", recorded_at: Time.zone.parse("2025-02-15 10:00:00"))
create(:mood, user: user, mode: mode, recorded_at: Time.zone.parse("2025-02-15 10:00:00")) create(:mood, mode: "calme", recorded_at: Time.zone.parse("2025-03-20 10:00:00"))
create(:mood, user: user, mode: mode2, recorded_at: Time.zone.parse("2025-03-20 10:00:00"))
result = MoodCalendarService.generate_calendar( result = MoodCalendarService.generate_calendar(
user, Mood.all,
end_date: Date.parse("2025-03-31") end_date: Date.parse("2025-03-31")
) )
february = result.find { |m| m[:month] == Date.parse("2025-02-01") } february = result.find { |m| m[:month] == Date.parse("2025-02-01") }
march = result.find { |m| m[:month] == Date.parse("2025-03-01") } march = result.find { |m| m[:month] == Date.parse("2025-03-01") }
# Février se termine le dimanche 2 mars
last_day_february = february[:weeks].flatten.last last_day_february = february[:weeks].flatten.last
expect(last_day_february[:recorded_at].to_date).to eq(Date.parse("2025-03-02")) expect(last_day_february[:recorded_at].to_date).to eq(Date.parse("2025-03-02"))
# Mars commence le lundi 3 mars
first_day_march = march[:weeks].flatten.first first_day_march = march[:weeks].flatten.first
expect(first_day_march[:recorded_at].to_date).to eq(Date.parse("2025-03-03")) expect(first_day_march[:recorded_at].to_date).to eq(Date.parse("2025-03-03"))
# Pas de chevauchement
expect(last_day_february[:recorded_at].to_date + 1.day).to eq(first_day_march[:recorded_at].to_date) expect(last_day_february[:recorded_at].to_date + 1.day).to eq(first_day_march[:recorded_at].to_date)
end end
end end
context 'avec un scope spécifique' do context 'avec un scope spécifique' do
it 'fonctionne avec des moods filtrés par user' do it 'fonctionne avec des moods filtrés par user' do
user1 = create(:user)
user2 = create(:user) user2 = create(:user)
mode2 = create(:mode, user: user2)
create(:mood, user: user, mode: mode, recorded_at: Time.zone.parse("2025-02-15 10:00:00"))
create(:mood, user: user2, mode: mode2, recorded_at: Time.zone.parse("2025-02-16 10:00:00"))
create(:mood, mode: "heureux", recorded_at: Time.zone.parse("2025-02-15 10:00:00"), user: user1)
create(:mood, mode: "triste", recorded_at: Time.zone.parse("2025-02-16 10:00:00"), user: user2)
moods = Mood.where(user: user1)
result = MoodCalendarService.generate_calendar( result = MoodCalendarService.generate_calendar(
user, moods,
end_date: Date.parse("2025-02-28") end_date: Date.parse("2025-02-28")
) )
all_days = result[0][:weeks].flatten all_days = result[0][:weeks].flatten
mood_15 = all_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-02-15") } mood_15 = all_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-02-15") }
expect(mood_15[:mode]).to eq(mode) expect(mood_15[:mode]).to eq("heureux")
# Le mood du user2 ne devrait pas apparaître
mood_16 = all_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-02-16") } mood_16 = all_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-02-16") }
expect(mood_16[:mode]).to be_nil expect(mood_16[:mode]).to be_nil
expect(mood_16[:guess]).to eq(mode) expect(mood_16[:guess]).to eq("heureux")
end
end
context 'avec day_log' do
it 'inclut le day_log dans les données du jour correspondant' do
create(:mood, user: user, mode: mode, recorded_at: Time.zone.parse("2025-02-15 10:00:00"))
day_log = create(:day_log, user: user, day: Date.parse("2025-02-15"), info: "Une belle journée")
result = MoodCalendarService.generate_calendar(user, end_date: Date.parse("2025-02-28"))
all_days = result.find { |m| m[:month] == Date.parse("2025-02-01") }[:weeks].flatten
day_15 = all_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-02-15") }
expect(day_15[:day_log]).to eq(day_log)
end
it 'retourne nil pour day_log quand il n\'en existe pas pour ce jour' do
create(:mood, user: user, mode: mode, recorded_at: Time.zone.parse("2025-02-15 10:00:00"))
result = MoodCalendarService.generate_calendar(user, end_date: Date.parse("2025-02-28"))
all_days = result.find { |m| m[:month] == Date.parse("2025-02-01") }[:weeks].flatten
day_15 = all_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-02-15") }
expect(day_15[:day_log]).to be_nil
end
it 'inclut le day_log pour un jour sans mood' do
create(:mood, user: user, mode: mode, recorded_at: Time.zone.parse("2025-02-15 10:00:00"))
day_log = create(:day_log, user: user, day: Date.parse("2025-02-16"), info: "Jour sans mood")
result = MoodCalendarService.generate_calendar(user, end_date: Date.parse("2025-02-28"))
all_days = result.find { |m| m[:month] == Date.parse("2025-02-01") }[:weeks].flatten
day_16 = all_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-02-16") }
expect(day_16[:mode]).to be_nil
expect(day_16[:day_log]).to eq(day_log)
end
it 'inclut le day_log pour un jour avec mood' do
create(:mood, user: user, mode: mode, recorded_at: Time.zone.parse("2025-02-15 10:00:00"))
day_log = create(:day_log, user: user, day: Date.parse("2025-02-15"), info: "Jour avec mood")
result = MoodCalendarService.generate_calendar(user, end_date: Date.parse("2025-02-28"))
all_days = result.find { |m| m[:month] == Date.parse("2025-02-01") }[:weeks].flatten
day_15 = all_days.find { |m| m[:recorded_at].to_date == Date.parse("2025-02-15") }
expect(day_15[:mode]).to eq(mode)
expect(day_15[:day_log]).to eq(day_log)
end end
end end
end end

View file

@ -43,6 +43,11 @@ braces@^3.0.3:
dependencies: dependencies:
fill-range "^7.1.1" fill-range "^7.1.1"
bulma@^1.0.4:
version "1.0.4"
resolved "https://registry.npmjs.org/bulma/-/bulma-1.0.4.tgz"
integrity sha512-Ffb6YGXDiZYX3cqvSbHWqQ8+LkX6tVoTcZuVB3lm93sbAVXlO0D6QlOTMnV6g18gILpAXqkG2z9hf9z4hCjz2g==
chokidar@^4.0.0: chokidar@^4.0.0:
version "4.0.3" version "4.0.3"
resolved "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz" resolved "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz"