redesign app on top of the kluk.fr charte
Some checks are pending
CI / scan_ruby (push) Waiting to run
CI / scan_js (push) Waiting to run
CI / lint (push) Waiting to run
CI / test (push) Waiting to run

- drop Bulma and Font Awesome, plain CSS built on charte.css
- dashboard: GitHub-like calendar grouped by year, selected day panel
  (sticky side column on desktop, bottom bar on mobile), keyboard navigation
- public page: today's mood and last months fit in one screen, journal hidden
- day log page: mode picker as pills, illustration follows the chosen mode
- login, password and invitation screens in French, global flash messages
- add User#current_mode_since

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Christophe Robillard 2026-09-29 07:35:53 +02:00
parent ad251e7558
commit 7a7fa4c095
37 changed files with 869 additions and 419 deletions

View file

@ -1,62 +0,0 @@
// Raccords entre Bulma et la charte : ce que ni l'un ni l'autre ne règle seul.
// Les titres Bulma suivent ceux de la charte : Young Serif en 400.
.title,
.subtitle {
font-family: var(--serif);
font-weight: 400;
letter-spacing: -0.005em;
}
// La charte souligne tous les liens ; pas les liens habillés en bouton ou en étiquette.
a.button,
a.tag,
a.navbar-item,
a.dropdown-item {
text-decoration: none;
}
// La charte ne prépare les <button class="bouton"> que dans un .formulaire :
// on les prépare partout (button_to, f.submit…).
button.bouton,
input.bouton {
border: 0;
font: inherit;
font-weight: 700;
cursor: pointer;
}
button.bouton:disabled,
input.bouton:disabled {
opacity: 0.6;
cursor: progress;
}
// button_to enveloppe son bouton dans un formulaire : le garder en ligne dans la navigation.
.nav form.button_to {
display: inline;
}
// La charte ne vise que les champs email et les zones de texte : on étend aux autres types.
.formulaire {
input[type="text"],
input[type="password"],
input[type="date"],
input[type="number"],
select {
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;
&:focus-visible {
outline: 3px solid var(--vert);
outline-offset: 1px;
border-color: var(--encre);
}
}
}

View file

@ -0,0 +1,485 @@
/*
* 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;
}
/* 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 ---------- */
/* Sur ordinateur, tout tient dans le premier écran : l'illustration à gauche,
l'humeur du jour et les derniers mois à droite. */
.public {
max-width: var(--largeur-entete);
margin: 0 auto;
padding: 0.5rem var(--gouttiere) 4rem;
display: grid;
gap: 1.75rem;
}
.carte-humeur-grande {
width: 100%;
max-width: 22rem;
}
.public-qui {
margin: 0;
font-size: 1.1rem;
color: var(--encre-douce);
}
.public-mode {
margin: 0.2rem 0 0.6rem;
font-size: clamp(2.25rem, 1.7rem + 2.4vw, 3.25rem);
}
.public-depuis {
margin: 0;
font-size: 1.1rem;
}
.public-historique h2 {
margin: 2.5rem 0 0.75rem;
font-size: 1.35rem;
}
.public-historique .annee-mois { margin-top: 1rem; }
@media (min-width: 52rem) {
.public {
grid-template-columns: min(20rem, 48vh) minmax(0, 1fr);
gap: 3.5rem;
align-items: start;
padding-top: 1rem;
}
.carte-humeur-grande { max-width: none; }
}
/* ---------- 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

@ -1,29 +0,0 @@
// Bulma, habillé aux couleurs et typographies de la charte Kluk (charte.css).
// Les valeurs reprennent les variables de charte.css : garder les deux en phase.
// Version sans mode sombre : la charte n'en a pas, et Bulma basculerait sinon
// champs, titres et fonds en sombre quand le système le demande.
@use 'bulma/versions/bulma-no-dark-mode' with (
$family-primary: ("Atkinson Hyperlegible Next", "Atkinson Hyperlegible", system-ui, sans-serif),
$family-secondary: ("Young Serif", Georgia, serif),
$primary: #2E2A26, // --encre : les boutons de la charte sont à l'encre
$link: #3F7A3B, // --vert-lien
$success: #5F9B59, // --vert
$warning: #EDA33D, // --orange
$danger: #D8573F, // --rouge
$info: #8D97A8, // --gris
$text: #2E2A26, // --encre
$text-strong: #2E2A26,
$text-weak: #5A534C, // --encre-douce
$scheme-main: #F5F6F0, // --porcelaine
$background: #E9ECE2, // --porcelaine-fonce
$border: #8A8279, // --bordure-champ
$radius: 0.5rem, // --arrondi-petit
$radius-large: 0.75rem, // --arrondi
);
// La charte vient après Bulma pour que ses règles de base l'emportent.
@use 'charte';
@use 'charte-bulma';
// Styles propres à l'application
@use 'kluk';

View file

@ -0,0 +1,4 @@
// 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

@ -3,7 +3,7 @@
* 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 ajustements pour Bulma sont dans _charte-bulma.scss.
* 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.

View file

@ -1,67 +0,0 @@
/*
* Styles propres à l'application (tableau de bord, journal).
* La charte graphique est dans charte.css, les raccords avec Bulma dans _charte-bulma.scss.
*/
.tracker {
align-content: center;
}
.logs {
height: 60vh;
overflow: scroll;
}
.current-day {
background-color: var(--porcelaine);
position: sticky;
top: 0;
align-content: center;
}
.bar {
width: 10px;
height: 10px;
}
.day {
border: 1px;
margin: 4px;
width: 15px;
height: 15px;
}
.day.has-day-log {
position: relative;
}
.day.has-day-log::after {
content: '';
position: absolute;
bottom: 1px;
right: 1px;
width: 4px;
height: 4px;
border-radius: 50%;
background-color: white;
}
.selected-day {
border-radius: 50%;
box-shadow: 0 0 0 3px var(--porcelaine), 0 0 0 5px var(--encre);
margin: 4px;
width: 15px;
height: 15px;
}
.empty-day {
border: 1px dashed var(--bordure-champ);
margin: 4px;
width: 15px;
height: 15px;
background: transparent;
}
.info {
margin: 30px;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,12 @@
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?
@ -10,30 +17,64 @@ module MoodsHelper
case day_status(mood, user)
when :filled then mood[:mode]
when :guessed then mood[:guess]
when :unknown, :empty then Mode.new(label: "unknown", color: "white")
end
end
=begin
def mode_for(mood, user)
if user.guess?
mood[:mode] || mood[:guess] || { label: "unknown", color: "white", image_url: "unknown.jpg" }
else
mood[:mode] || { label: "unknown", color: "white", image_url: "unknown.jpg" }
end
end
=end
def style_for_mode(mode, status)
# 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
def libelle_statut(status)
case status
when :empty then ""
when :unknown then "background-color: #{mode.color}; border: 2px double grey;"
else "background-color: #{mode.color};"
when :guessed then "supposé"
when :unknown then "pas de tasse posée"
end
end
def css_class_for_day(status, day_log)
css_class = status == :empty ? "day empty-day" : "day"
css_class += " has-day-log" if day_log
css_class
# Un jour du calendrier : un bouton pour le propriétaire, une simple case pour les visiteurs.
def case_jour(mood, user, interactive:, selected: false)
date = mood[:recorded_at].to_date
status = day_status(mood, user)
mode = mode_for(mood, user)
day_log = mood[:day_log] if interactive
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
tag.span(**options, "aria-hidden": true)
end
end
end

View file

@ -0,0 +1,56 @@
import { Controller } from "@hotwired/stimulus"
// Calendrier des humeurs : choisir un jour affiche son détail (illustration, mode, journal).
// 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", "journal", "lien" ]
connect() {
const selectionne = this.element.querySelector(".jour[aria-pressed='true']")
if (selectionne && new URLSearchParams(window.location.search).has("day")) {
selectionne.scrollIntoView({ block: "center" })
}
}
choisir(event) {
this.selectionner(event.currentTarget)
}
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
const { day, date, mode, statut, couleur, image, info } = jour.dataset
this.carteTarget.style.setProperty("--humeur", couleur || "")
this.imageTarget.src = image
this.dateTarget.textContent = date
this.modeTarget.textContent = mode || "Pas de tasse posée"
this.statutTarget.textContent = mode ? (statut || "") : ""
this.journalTarget.textContent = info || this.journalTarget.dataset.vide
this.journalTarget.classList.toggle("detail-journal-vide", !info)
this.lienTarget.href = `/day_logs/edit?day=${day}`
this.lienTarget.textContent = info ? "Modifier le journal" : "Écrire dans le journal"
history.replaceState({}, "", `?day=${day}`)
}
}

View file

@ -0,0 +1,12 @@
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

@ -1,44 +0,0 @@
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = [ "image", "modeDay", "dayLogLink", "dayLogInfo" ];
connect() {
this.lastTarget = null;
const params = new URLSearchParams(window.location.search);
const selectedDay = params.get("day");
if (selectedDay) {
const dayEl = document.querySelector(`[data-day="${selectedDay}"]`);
if (dayEl) {
dayEl.click();
dayEl.scrollIntoView({ block: "center" });
}
} else {
const logs = document.querySelector(".logs");
if (logs) logs.scrollTop = logs.scrollHeight;
}
}
updateDayInfo(event) {
const image = this.imageTarget;
const modeDay = this.modeDayTarget;
const day = event.target.dataset.day;
const formattedDay = new Date(day).toLocaleDateString('fr-FR');
const modeDayContent = formattedDay + ' : ' + event.target.dataset.mode;
image.src = event.target.dataset.image;
modeDay.textContent = modeDayContent;
this.dayLogLinkTarget.href = `/day_logs/edit?day=${day}`;
this.dayLogInfoTarget.textContent = event.target.dataset.info || '';
event.target.className = "selected-day " + event.target.dataset.mode;
history.pushState({}, '', `?day=${day}`);
if (this.lastTarget) {
this.lastTarget.className = "day " + this.lastTarget.dataset.mode;
}
this.lastTarget = event.target;
}
}

View file

@ -40,6 +40,17 @@ class User < ApplicationRecord
self.moods.last&.mode || "croisiere"
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

View file

@ -3,7 +3,7 @@
<nav class="nav" aria-label="Navigation principale">
<ul>
<% if authenticated? %>
<li><%= lien_nav "Mon tableau de bord", dashboard_path %></li>
<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>

View file

@ -0,0 +1,10 @@
<% 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

@ -34,14 +34,5 @@
<%= f.submit "Envoyer", class: "bouton" %>
<% end %>
<h2>Bulma aux couleurs de la charte</h2>
<div class="buttons">
<a class="button is-primary" href="#">is-primary</a>
<a class="button is-link" href="#">is-link</a>
<a class="button is-light" href="#">is-light</a>
</div>
<p class="title is-4">Titre Bulma</p>
<div class="notification is-danger">Notification is-danger</div>
<%= render "charte/note", texte: "Une note discrète en fin de page." %>
</article>

View file

@ -1,25 +0,0 @@
= form_with model: day_log do |f|
- @day_log.errors.full_messages.each do |msg|
.notification.is-danger= msg
.field
= f.label :info, "Journal", class: "label"
.control
= f.text_area :info, class: "textarea", rows: 5
.field
= label_tag :mode_id, "Mode", class: "label"
.control
.select.is-fullwidth
= select_tag "day_log[mode_id]",
options_from_collection_for_select(Current.user.modes, :id, :label, @mood&.mode_id),
include_blank: true
.field.is-hidden
= f.date_field :day
.field.is-grouped
.control
= f.submit "Enregistrer", class: "button is-primary"
.control
= link_to "Annuler", dashboard_path(day: @day_log.day), class: "button is-light"

View file

@ -0,0 +1,44 @@
<% 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

@ -1,13 +0,0 @@
.columns.m-auto
.current-day.column.is-hidden-mobile
%section.m-4
%figure.image.has-ratio
= image_tag @mood ? @mood.mode.image_url : "unknown.jpg"
%section.section.column.has-background-primary-light
%h1.title.is-4
%span.icon
%i.fa-regular.fa-calendar
%span= l @day_log.day, format: :long
= render 'form', day_log: @day_log

View file

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

View file

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

View file

@ -1,26 +1,16 @@
<article class="texte">
<div>
<h1>Set up your password</h1>
<p>Please set your password to activate your account.</p>
<% content_for :title, "Bienvenue – Kluk" %>
<article class="texte texte-etroit">
<%= render "charte/tasses" %>
<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 do |form| %>
<div>
<%= 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>
<%= form_with model: @user, url: invitation_path(token: params[:token]), method: :patch, class: "formulaire" do |form| %>
<%= render "charte/champ", form: form, attribut: :password, libelle: "Mot de passe" do %>
<%= form.password_field :password, required: true, autocomplete: "new-password", maxlength: 72 %>
<% end %>
<%= 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", maxlength: 72 %>
<% end %>
<%= form.submit "Activer mon compte", class: "bouton" %>
<% end %>
</div>
</article>

View file

@ -21,8 +21,6 @@
<%= preload_link_tag "young-serif-400.woff2" %>
<%= preload_link_tag "atkinson-hyperlegible-next-400.woff2" %>
<script src="https://kit.fontawesome.com/079b06296f.js" crossorigin="anonymous"></script>
<%# Includes all stylesheet files in app/assets/stylesheets %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
@ -32,6 +30,7 @@
<a class="evitement" href="#contenu">Aller au contenu</a>
<%= render "entete" %>
<main id="contenu" tabindex="-1">
<%= render "flash" %>
<%= yield %>
</main>
<%= render "pied" %>

View file

@ -0,0 +1,30 @@
<%# 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, 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,
selected: mood[:recorded_at].to_date == selected_date) %>
<% end %>
</div>
</div>
<% end %>
</div>
<% end %>
<% end %>
</div>

View file

@ -0,0 +1,18 @@
<%# 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,63 +1,31 @@
<div data-controller="mood" class="columns m-auto">
<div class="current-day column is-hidden-mobile">
<section class="m-4">
<figure class="image has-ratio">
<%= image_tag(@user.current_mode.image_url, "data-mood-target": "image") %>
</figure>
</section>
</div>
<section class="section column has-background-primary-light is-flex is-flex-direction-column is-justify-content-space-around">
<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, :filled) %>"></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 edit_day_log_for_day_path(day: Date.today), "data-mood-target": "dayLogLink" do %>
<span class="icon ml-2"><i class="has-text-grey fa-solid fa-pencil fa-xs"></i></span>
<% end %>
</div>
<p data-mood-target="dayLogInfo"><%= @user.current_day_log&.info %></p>
<% content_for :title, "Mes humeurs – Kluk" %>
<% history = @user.history %>
<% selected_date = (Date.parse(params[:day]) rescue Date.current) %>
<% selected = find_day(history, selected_date) || find_day(history, selected_date = Date.current) %>
<% status = day_status(selected, @user) if selected %>
<% 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, selected_date: selected_date %>
</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| %>
<% status = day_status(mood, @user) %>
<% mode = mode_for(mood, @user) %>
<div data-image="<%= mode.image_url %>"
data-mode="<%= mode.label %>"
data-day="<%= mood[:recorded_at].to_date.iso8601 %>"
data-info="<%= mood[:day_log]&.info %>"
data-action="click->mood#updateDayInfo"
title="<%= l(mood[:recorded_at].to_date) %> : <%= mode.label %>"
class="<%= css_class_for_day(status, mood[:day_log]) %>"
style="<%= style_for_mode(mode, status) %>">
<aside class="detail" aria-live="polite" aria-label="Jour sélectionné">
<figure class="carte-humeur" data-calendrier-target="carte" style="<%= "--humeur: #{mode.color}" if mode %>">
<%= image_tag (mode || Mode.new).image_url, alt: "", data: { calendrier_target: "image" } %>
</figure>
<div class="detail-texte">
<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>
<% end %>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
</div>
</section>
</aside>
</div>

View file

@ -0,0 +1,30 @@
<% content_for :title, "Comment ça Kluk chez #{@user.username} ?" %>
<% mode = @user.moods.last&.mode %>
<% since = @user.current_mode_since %>
<article class="public">
<figure class="carte-humeur carte-humeur-grande" style="<%= "--humeur: #{mode.color}" if mode %>">
<%= image_tag (mode || Mode.new).image_url, alt: "" %>
</figure>
<div class="public-texte">
<p class="public-qui">Aujourd’hui chez <%= @user.username %></p>
<h1 class="public-mode"><%= mode&.label || "Pas encore de tasse posée" %></h1>
<% if since %>
<p class="public-depuis">
<% if since == Date.current %>
Depuis ce matin.
<% else %>
Depuis le <%= l(since, format: since.year == Date.current.year ? "%A %-d %B" : "%-d %B %Y") %>.
<% end %>
</p>
<% end %>
<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", user: @user, par_annee: false,
history: MoodCalendarService.generate_calendar(@user, start_date: 5.months.ago.beginning_of_month, end_date: Date.current) %>
</section>
</div>
</article>

View file

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

View file

@ -1,10 +1,13 @@
<article class="texte">
<h1>Forgot your password?</h1>
<% content_for :title, "Mot de passe oublié – Kluk" %>
<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>
<%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
<%= form_with url: passwords_path do |form| %>
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %><br>
<%= form.submit "Email reset instructions" %>
<%= form_with url: passwords_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 %>
<%= form.submit "Recevoir le lien", class: "bouton" %>
<p class="formulaire-secours"><%= link_to "Retour à la connexion", new_session_path %></p>
<% end %>
</article>

View file

@ -0,0 +1,16 @@
<% 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

@ -1,22 +0,0 @@
.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

7
package-lock.json generated
View file

@ -6,7 +6,6 @@
"": {
"name": "app",
"dependencies": {
"bulma": "^1.0.4",
"sass": "^1.94.2",
"yarn": "^1.22.22"
}
@ -320,12 +319,6 @@
"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": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",

View file

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

View file

@ -1,5 +1,21 @@
require 'rails_helper'
RSpec.describe User, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
describe "#current_mode_since" do
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

View file

@ -43,11 +43,6 @@ braces@^3.0.3:
dependencies:
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:
version "4.0.3"
resolved "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz"