diff --git a/CLAUDE.md b/CLAUDE.md
index bb083a1..6d47c33 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -54,10 +54,12 @@ docker compose up -d --build # → http://127.0.0.1:53487/
l'issue #43 ; `logo_url` n'est plus saisi mais déduit du favicon, issue #50),
`Candidature` (cœur
du suivi, étapes de progression + `motif_cloture` = clôture ; `cv` = CV joint,
-issue #49), `StatusHistory`,
+issue #49 ; `localisation` = zone géographique de l'offre, issue #52),
+`StatusHistory`,
`Reminder`, `Interview`, `Contact`, `ApiToken`, `CV` (avec analyse IA des
informations principales — champs `analysis`/`analyzed_at`/… , issue #44 ;
-`actif` = archivage, issue #48),
+`actif` = archivage, issue #48 ; `par_defaut` = CV dont l'adresse sert d'origine
+aux trajets, issue #52),
`AIConfig` (singleton de
config du coaching IA, clé Gemini chiffrée — issue #33). Énumérations
`TextChoices` : `Source`, `Canal`, `Statut`, `MotifCloture` (certaines avec icône
@@ -68,6 +70,12 @@ emoji dans le libellé pour les menus).
- Logos dérivés du favicon : `tracking/logos.py` (`favicon_service_url`, stdlib
uniquement) ; chargé par défaut à l'enregistrement d'un site (issue #27).
- Géométrie du donut de stats : `tracking/statistics.py`.
+- Temps de trajet (issue #52) : la fiche candidature géocode la `localisation`
+ de l'offre et l'adresse du **CV par défaut** (`CV.par_defaut`,
+ `CV.home_location`) via Nominatim, puis calcule un itinéraire **routier** par
+ OSRM côté client (aucune clé). Un lien 🚆 renvoie vers Google Maps pour le
+ calcul en transport en commun. Le plugin Chrome récupère la zone géographique
+ via `jobLocation` (schema.org) puis sélecteurs DOM.
- Coaching IA (issues #33, #34, #39) : `tracking/ai.py` (clients REST stdlib pour
**Gemini, Mistral, OpenAI/ChatGPT, Anthropic/Claude, Perplexity** ;
`generate(..., provider=...)` aiguille — OpenAI/Mistral/Perplexity partagent le
diff --git a/chrome-extension/GUIDE.md b/chrome-extension/GUIDE.md
new file mode 100644
index 0000000..b63f6ed
--- /dev/null
+++ b/chrome-extension/GUIDE.md
@@ -0,0 +1,131 @@
+# Guide pas à pas — Enregistrer une annonce LinkedIn dans CandiTrack
+
+Objectif : depuis une offre d'emploi affichée sur LinkedIn, créer la candidature
+correspondante dans CandiTrack **en un clic** grâce à l'extension Chrome.
+
+> ℹ️ L'extension ne se connecte pas à LinkedIn et n'utilise aucune API LinkedIn.
+> Elle lit simplement les informations de **la page que vous avez ouverte** (titre du
+> poste, entreprise, URL) et les envoie à **votre** instance CandiTrack.
+
+---
+
+## Étape 0 — Prérequis (à faire une seule fois)
+
+### 0.1 Démarrer le backend avec un jeton
+Dans un terminal, à la racine du projet :
+```powershell
+.venv\Scripts\Activate.ps1
+```
+Si `CANDITRACK_API_TOKEN` n'est pas encore dans votre `.env`, générez-en un :
+```powershell
+python -c "import secrets; print(secrets.token_urlsafe(32))"
+```
+Copiez la valeur dans `.env` :
+```
+CANDITRACK_API_TOKEN=la_valeur_generee
+```
+Puis lancez le serveur :
+```powershell
+python manage.py runserver
+```
+Laissez ce terminal ouvert. CandiTrack tourne sur **http://127.0.0.1:8000/**.
+
+### 0.2 Installer l'extension
+1. Ouvrez **`chrome://extensions`** dans Chrome.
+2. Activez le **Mode développeur** (interrupteur en haut à droite).
+3. Cliquez **Charger l'extension non empaquetée**.
+4. Sélectionnez le dossier **`chrome-extension/`** du projet.
+5. L'extension **CandiTrack** apparaît. Cliquez sur l'icône 🧩 (puzzle) de Chrome puis
+ sur l'épingle à côté de CandiTrack pour **l'afficher** dans la barre d'outils.
+
+### 0.3 Configurer l'extension
+1. Clic **droit** sur l'icône CandiTrack → **Options**.
+2. **URL du backend** : `http://127.0.0.1:8000`
+3. **Jeton API** : collez la **même valeur** que `CANDITRACK_API_TOKEN` de votre `.env`.
+4. Cliquez **Enregistrer** → vous devez voir « Enregistré ✓ ».
+
+✅ Tout est prêt. Les étapes suivantes sont à refaire pour **chaque** annonce.
+
+---
+
+## Étape 1 — Ouvrir l'annonce sur LinkedIn
+
+1. Allez sur **https://www.linkedin.com/jobs/** et lancez une recherche (mot-clé + lieu).
+2. Dans les résultats, **cliquez sur une offre** : son détail s'affiche à droite.
+3. **Important** : ouvrez l'offre sur sa **page dédiée** pour une extraction fiable.
+ - soit cliquez sur le titre de l'offre / le bouton qui ouvre la page complète,
+ - l'URL doit ressembler à `https://www.linkedin.com/jobs/view/4039xxxxxx/`.
+4. Attendez que la page soit **entièrement chargée** (titre du poste + nom de l'entreprise visibles).
+
+---
+
+## Étape 2 — Ouvrir l'extension et vérifier le pré-remplissage
+
+1. Cliquez sur l'icône **CandiTrack** dans la barre d'outils.
+2. Une petite **popup** s'ouvre et tente de remplir automatiquement :
+ - **Entreprise** → ex. « ACME France »
+ - **URL de l'offre** → l'adresse `.../jobs/view/...`
+ - **Localisation** → la zone géographique de l'offre (ex. « Lyon, France »)
+ - **Source** → affichée en bas : `linkedin`
+
+> ℹ️ Le plugin **ne capture volontairement pas l'intitulé du poste ni la date d'envoi** :
+> vous les compléterez dans CandiTrack une fois la candidature réellement envoyée.
+> Si l'entreprise est vide ou inexacte (LinkedIn change parfois sa mise en page),
+> **corrigez-la** dans la popup. L'URL, elle, est toujours correcte.
+
+---
+
+## Étape 3 — Ajouter la candidature
+
+1. Vérifiez/ajustez les champs.
+2. Cliquez **Ajouter**.
+3. La popup affiche **« Ajouté ✓ »** avec un lien **voir**.
+ - Le bouton se réactive et un message d'erreur s'affiche si quelque chose ne va pas
+ (voir Dépannage).
+
+---
+
+## Étape 4 — Vérifier dans CandiTrack
+
+1. Ouvrez (ou rafraîchissez) **http://127.0.0.1:8000/**.
+2. La nouvelle candidature apparaît dans la liste :
+ - **Libellé** = le nom de l'entreprise
+ - **Source** = LinkedIn, **URL de l'offre** cliquable
+ - **Poste** et **date d'envoi** restent **vides** (à compléter)
+3. Cliquez sur son libellé pour ouvrir le détail et compléter le poste, la date d'envoi,
+ les étapes, les dates d'entretien, etc.
+
+🎉 L'annonce LinkedIn est enregistrée.
+
+---
+
+## Dépannage
+
+| Symptôme | Cause probable | Solution |
+|---|---|---|
+| « Configurez l'URL et le jeton… » | Options non remplies | Refaire l'**étape 0.3** |
+| « Erreur : unauthorized » (401) | Jeton de l'extension ≠ `CANDITRACK_API_TOKEN` du `.env` | Recopier exactement le jeton, **Enregistrer**, réessayer |
+| « Erreur réseau… » | Serveur arrêté ou mauvaise URL backend | Vérifier que `runserver` tourne et que l'URL est `http://127.0.0.1:8000` |
+| Entreprise vide dans la popup | Page pas finie de charger, ou structure LinkedIn différente | Recharger l'annonce, rouvrir la popup, sinon **saisir à la main** |
+| Rien ne se passe au clic sur l'icône | Vous n'êtes pas sur un onglet de page web (ex. `chrome://`) | Ouvrir l'extension depuis l'**onglet de l'annonce** |
+| Modifs de l'extension non prises en compte | Cache de l'extension | `chrome://extensions` → bouton **⟳** sur la carte CandiTrack |
+
+### Tester sans LinkedIn (vérifier que la chaîne fonctionne)
+Sur n'importe quelle page d'offre (Indeed, Welcome to the Jungle…), la même procédure
+s'applique. Pour un test purement backend, voir la section 3 de **`../TESTING.md`**
+(`Invoke-RestMethod` vers `/api/candidatures/`).
+
+---
+
+## Notes et limites
+
+- **Page dédiée recommandée** : sur la page de recherche LinkedIn (`/jobs/search/`),
+ plusieurs offres coexistent ; ouvrez l'offre sur sa page `/jobs/view/...` pour que
+ l'entreprise et le poste soient extraits de manière fiable.
+- **Pas de connexion requise côté extension** : elle lit la page visible. Si l'offre n'est
+ visible que connecté à LinkedIn, restez connecté dans votre onglet — l'extension lit
+ alors ce que **vous** voyez.
+- **Doublons** : cliquer « Ajouter » deux fois crée deux candidatures. Supprimez l'éventuel
+ doublon depuis CandiTrack.
+- **Autre machine / hôte** : si votre backend n'est pas en local, ajoutez son URL dans
+ `host_permissions` du `manifest.json`, puis rechargez l'extension.
diff --git a/chrome-extension/popup.html b/chrome-extension/popup.html
index fe7b946..4bf838d 100644
--- a/chrome-extension/popup.html
+++ b/chrome-extension/popup.html
@@ -24,6 +24,8 @@
Ajouter à CandiTrack
+
+
Source : —
diff --git a/chrome-extension/popup.js b/chrome-extension/popup.js
index 16ebf96..66168ad 100644
--- a/chrome-extension/popup.js
+++ b/chrome-extension/popup.js
@@ -16,7 +16,22 @@ function extractFromPage() {
return el ? (el.textContent || "").trim() : "";
}
+ // Compose a readable location string from a schema.org PostalAddress (issue #52).
+ function formatAddress(addr) {
+ if (!addr) return "";
+ if (typeof addr === "string") return addr;
+ if (Array.isArray(addr)) return formatAddress(addr[0]);
+ if (addr["@type"] === "Place" && addr.address) return formatAddress(addr.address);
+ var parts = [addr.streetAddress, addr.postalCode, addr.addressLocality,
+ addr.addressRegion, addr.addressCountry];
+ return parts
+ .map(function (p) { return p && typeof p === "object" ? (p.name || "") : (p || ""); })
+ .filter(function (p) { return p; })
+ .join(", ");
+ }
+
var entreprise = "";
+ var localisation = "";
// 1) schema.org JobPosting (JSON-LD) — most reliable when present.
var scripts = document.querySelectorAll('script[type="application/ld+json"]');
@@ -34,7 +49,12 @@ function extractFromPage() {
if (isJob && node.hiringOrganization) {
var org = node.hiringOrganization;
var name = typeof org === "string" ? org : org.name;
- if (name) { entreprise = String(name); break; }
+ if (name) { entreprise = String(name); }
+ }
+ // Zone géographique de l'offre (issue #52).
+ if (isJob && !localisation && node.jobLocation) {
+ var loc = Array.isArray(node.jobLocation) ? node.jobLocation[0] : node.jobLocation;
+ if (loc) localisation = formatAddress(loc.address || loc);
}
}
} catch (e) { /* ignore malformed JSON-LD */ }
@@ -43,10 +63,11 @@ function extractFromPage() {
// 2) Site-specific DOM selectors (pages without JSON-LD, e.g. LinkedIn signed-in).
if (!entreprise) {
var selectors = [
- ".topcard__org-name-link", // LinkedIn (déconnecté)
- ".topcard__flavor", // LinkedIn (déconnecté, variante)
- ".job-details-jobs-unified-top-card__company-name", // LinkedIn (connecté)
- ".jobs-unified-top-card__company-name", // LinkedIn (ancienne UI)
+ ".topcard__org-name-link", // LinkedIn (déconnecté)
+ ".topcard__flavor", // LinkedIn (déconnecté, variante)
+ ".job-details-jobs-unified-top-card__company-name a", // LinkedIn (connecté, lien société)
+ ".job-details-jobs-unified-top-card__company-name", // LinkedIn (connecté)
+ ".jobs-unified-top-card__company-name", // LinkedIn (ancienne UI)
'[data-testid="inlineHeader-companyName"]', // Indeed
'[data-testid="company-name"]', // Indeed (variante)
".jobsearch-CompanyInfoContainer a", // Indeed (ancienne UI)
@@ -57,6 +78,42 @@ function extractFromPage() {
}
}
+ // 2bis) Sélecteurs DOM pour la localisation (issue #52).
+ if (!localisation) {
+ var locSelectors = [
+ ".topcard__flavor--bullet", // LinkedIn (déconnecté)
+ ".job-details-jobs-unified-top-card__bullet", // LinkedIn (ancienne UI connectée)
+ '[data-testid="inlineHeader-companyLocation"]', // Indeed
+ '[data-testid="job-location"]', // Indeed (variante)
+ '[data-testid="jobsearch-JobInfoHeader-companyLocation"]', // Indeed (ancienne UI)
+ ".location", // génériques (APEC, Monster…)
+ ];
+ for (var m = 0; m < locSelectors.length && !localisation; m++) {
+ localisation = text(locSelectors[m]);
+ }
+ }
+
+ // 2ter) LinkedIn (UI connectée récente, vues /jobs/view et /jobs/collections) :
+ // la localisation est le 1er segment (avant « · ») du conteneur de description
+ // sous le titre, mêlée à la date de publication et au nombre de candidats.
+ if (!localisation) {
+ var descSelectors = [
+ ".job-details-jobs-unified-top-card__primary-description-container",
+ ".job-details-jobs-unified-top-card__tertiary-description-container",
+ ".jobs-unified-top-card__primary-description",
+ ];
+ for (var p = 0; p < descSelectors.length && !localisation; p++) {
+ var raw = text(descSelectors[p]);
+ if (!raw) continue;
+ var segs = raw.split(/[·•|]/)
+ .map(function (s) { return s.replace(/\s+/g, " ").trim(); })
+ .filter(function (s) { return s; });
+ // Ignore un éventuel 1er segment qui répète le nom de l'entreprise.
+ if (segs.length && entreprise && segs[0] === entreprise) segs.shift();
+ if (segs.length) localisation = segs[0];
+ }
+ }
+
// 3) Open Graph — but ignore generic job-board names (LinkedIn, Indeed…).
if (!entreprise) {
var og = meta("og:site_name");
@@ -64,7 +121,7 @@ function extractFromPage() {
if (og && !generic.test(og)) entreprise = og;
}
- return { url: location.href, entreprise: entreprise.trim() };
+ return { url: location.href, entreprise: entreprise.trim(), localisation: localisation.trim() };
}
function sourceFromUrl(url) {
@@ -117,6 +174,7 @@ document.addEventListener("DOMContentLoaded", function () {
var data = results[0].result || {};
if (data.entreprise) document.getElementById("entreprise").value = data.entreprise;
if (data.url) document.getElementById("url").value = data.url;
+ if (data.localisation) document.getElementById("localisation").value = data.localisation;
}
);
});
@@ -131,6 +189,7 @@ document.addEventListener("DOMContentLoaded", function () {
var payload = {
url: document.getElementById("url").value.trim(),
entreprise: document.getElementById("entreprise").value.trim(),
+ localisation: document.getElementById("localisation").value.trim(),
source: current.source
};
btn.disabled = true;
diff --git a/templates/base.html b/templates/base.html
index 36e0518..ac638a5 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -158,6 +158,22 @@
.row-actions a, .row-actions form { margin-right:0.5rem; }
.linklike { background:none; border:none; padding:0; color:var(--accent); cursor:pointer;
font:inherit; text-decoration:underline; }
+ /* Actions de ligne sous forme d'icônes (issue #52). */
+ .act-row { display:flex; gap:0.35rem; align-items:center; flex-wrap:nowrap; white-space:nowrap; }
+ .act-row form { margin:0; display:inline-flex; }
+ .icon-btn, .icon-btn:visited, .icon-btn:hover { display:inline-flex; align-items:center;
+ justify-content:center; width:34px; height:34px; padding:0; border:1px solid var(--border);
+ border-radius:var(--radius-sm); background:var(--card); color:var(--text); cursor:pointer;
+ font-size:1rem; line-height:1; text-decoration:none; box-shadow:var(--shadow);
+ transition:transform .12s ease, filter .15s, background .15s, border-color .15s; }
+ .icon-btn:hover { transform:translateY(-1px); background:var(--chip-bg); box-shadow:var(--shadow-lg); }
+ .icon-btn:active { transform:translateY(0); }
+ .icon-btn.danger:hover { background:rgba(224,88,75,0.16); border-color:#e0584b; }
+ /* Étoile « CV par défaut » (issue #52) : on/off graphique. */
+ .icon-btn.star { filter:grayscale(1); opacity:0.55; }
+ .icon-btn.star:hover { filter:none; opacity:1; }
+ .icon-btn.star.is-default { filter:none; opacity:1; border-color:var(--accent);
+ background:var(--accent-soft); }
/* Progress bars / steps, shared across pages */
.bar-track { display:block; background:var(--track); border-radius:6px; height:0.85rem; overflow:hidden; }
.bar-fill { display:block; height:100%; background:var(--accent); border-radius:6px; min-width:2px; }
@@ -193,6 +209,19 @@
.closed-row { opacity:0.55; }
.closed-row td { background:rgba(224,88,75,0.06); }
.src-logo { height:16px; width:16px; object-fit:contain; vertical-align:middle; margin-right:0.35rem; border-radius:3px; }
+ /* Logo + libellé de la source sur une seule ligne (issue #52). */
+ .src-cell { display:inline-flex; align-items:center; white-space:nowrap; }
+ /* Menu déroulant (téléchargement d'un CV par format, issue #52). */
+ .dd { position:relative; display:inline-block; }
+ .dd > summary { list-style:none; cursor:pointer; }
+ .dd > summary::-webkit-details-marker { display:none; }
+ .dd-menu { position:absolute; z-index:40; top:calc(100% + 4px); left:0; min-width:200px;
+ background:var(--card); border:1px solid var(--border); border-radius:var(--radius-sm);
+ box-shadow:var(--shadow-lg); padding:0.3rem; }
+ .dd-menu a { display:block; padding:0.42rem 0.6rem; border-radius:var(--radius-sm); color:var(--text);
+ text-decoration:none; font-size:0.9rem; white-space:nowrap; }
+ .dd-menu a:hover { background:var(--chip-bg); }
+ .dd-sep { height:1px; background:var(--border); margin:0.3rem 0; }
/* Toasts (issue #13) */
.toast-container { position:fixed; top:1rem; right:1rem; z-index:1500;
display:flex; flex-direction:column; gap:0.5rem; max-width:360px; }
@@ -321,6 +350,13 @@
+
+{% if candidature.localisation and home_location %}
+
+{% endif %}
{% endblock %}
diff --git a/templates/tracking/candidature_list.html b/templates/tracking/candidature_list.html
index 92d6b71..e033078 100644
--- a/templates/tracking/candidature_list.html
+++ b/templates/tracking/candidature_list.html
@@ -41,16 +41,19 @@
Candidatures
{{ c.entreprise|default:"—" }}
{{ c.poste|default:"—" }}
- {% if c.source_logo %}{% endif %}{{ c.get_source_display }}
+ {% if c.source_logo %}{% endif %}{{ c.get_source_display }}